From d99a5b1bf81c7b7b7971074f54227b2ed7c7fad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 26 Jun 2026 15:16:12 +0800 Subject: [PATCH] Advance UIKit rewrite with AMap integration and core UI modules. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor --- AGENTS.md | 7 + Podfile | 75 ++ Podfile.lock | 20 +- Scripts/add_swift_doc_comments.py | 280 ++++++++ Scripts/polish_swift_doc_comments.py | 471 +++++++++++++ suixinkan_ios.xcodeproj/project.pbxproj | 36 +- suixinkan_ios/App/AMapBootstrap.swift | 5 +- suixinkan_ios/App/AppServices.swift | 1 + suixinkan_ios/App/AppUITestRouteDriver.swift | 2 +- .../AppRouteViewControllerFactory.swift | 2 + suixinkan_ios/App/Navigation/AppTab.swift | 38 +- .../App/Navigation/NavigationRouter.swift | 1 + .../Navigation/TabNavigationController.swift | 18 +- suixinkan_ios/App/State/ToastCenter.swift | 4 + .../ViewControllers/RootViewController.swift | 11 + suixinkan_ios/AppDelegate.swift | 61 +- .../AppIcon.appiconset/Contents.json | 3 + .../AppIcon.appiconset/icon_1024.png | Bin 0 -> 108416 bytes .../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 .../Design/AppContentUnavailableView.swift | 1 + suixinkan_ios/Core/Design/AppMetrics.swift | 1 + .../Core/Design/GlobalLoadingCenter.swift | 4 + .../Location/ForegroundLocationProvider.swift | 1 + suixinkan_ios/Core/Map/Map.md | 16 + .../Core/Map/OperatingAreaMapView.swift | 199 ++++++ .../Core/Map/PunchPointMapPickerView.swift | 139 ++++ .../Core/Push/PushNotificationManager.swift | 5 + suixinkan_ios/Core/Push/PushPayload.swift | 4 + .../Queue/ScenicQueueAnnouncementState.swift | 1 + .../Core/Queue/ScenicQueueRuntime.swift | 18 + .../Core/Queue/ScenicQueueSocketClient.swift | 4 + .../UI/FeaturePlaceholderViewController.swift | 2 + suixinkan_ios/Core/UI/RemoteImage.swift | 4 + .../Core/UI/ViewControllerHelpers.swift | 6 + .../Core/UIKit/ListDiffableSupport.swift | 173 +++++ .../UIKit/ModuleViewControllerSupport.swift | 235 ++++++- .../AssetsViewControllers.swift | 31 + .../AccountSelectionViewController.swift | 82 ++- .../ViewControllers/LoginViewController.swift | 25 + .../ViewControllers/HomeMenuRouting.swift | 1 + .../HomeMoreFunctionsViewController.swift | 259 ++++--- .../HomePlaceholderViewController.swift | 2 + .../ViewControllers/HomeViewController.swift | 655 ++++++++++++------ .../InviteViewControllers.swift | 12 + .../Invite/ViewModels/InviteViewModels.swift | 4 + suixinkan_ios/Features/Live/API/LiveAPI.swift | 12 + .../Features/Live/Models/LiveModels.swift | 25 + .../ViewControllers/LiveViewControllers.swift | 9 + .../ViewModels/LivePlaybackViewModel.swift | 12 + .../LivePushReadinessViewModel.swift | 33 + .../Live/ViewModels/LiveViewModels.swift | 8 + .../Models/LocationReportModels.swift | 2 + .../LocationReportViewControllers.swift | 24 +- suixinkan_ios/Features/Main/Main.md | 45 +- .../MainTabBarController.swift | 318 ++------- .../PlaceholderViewController.swift | 2 + .../Models/MessageCenterModels.swift | 2 + .../MessageCenterViewController.swift | 8 + .../ViewModels/MessageCenterViewModel.swift | 2 + .../OperatingArea/API/OperatingAreaAPI.swift | 2 + .../Models/OperatingAreaModels.swift | 20 + .../OperatingAreaViewController.swift | 135 +++- .../ViewModels/OperatingAreaViewModel.swift | 3 + .../Features/Orders/Models/OrderModels.swift | 21 + .../DepositOrderViewControllers.swift | 353 +++++++--- .../OrderCodeScannerViewController.swift | 17 + .../OrderDetailViewControllers.swift | 361 +++++++--- .../OrderTailUploadViewControllers.swift | 134 +++- .../OrdersViewController.swift | 548 ++++++++++----- .../ViewModels/OrderLongTailViewModels.swift | 12 + .../PaymentCollectionViewController.swift | 5 + .../API/PilotCertificationAPI.swift | 4 + .../Models/PilotCertificationModels.swift | 10 + .../PilotCertificationViewController.swift | 21 +- .../PilotCertificationViewModel.swift | 9 + .../AccountSwitchViewController.swift | 126 +++- .../ProfileViewController.swift | 161 +++-- .../RealNameAuthViewController.swift | 14 + .../SettingsViewControllers.swift | 132 ++-- .../ProjectViewControllers.swift | 29 + .../ViewModels/ProjectViewModels.swift | 12 + .../PunchPoint/Models/PunchPointModels.swift | 4 + .../PunchPointViewControllers.swift | 140 +++- .../QueueManagement/API/ScenicQueueAPI.swift | 12 + .../Models/ScenicQueueModels.swift | 15 + .../Models/ScenicQueueSettingsModels.swift | 11 + .../Models/ScenicQueueSettingsStore.swift | 3 + .../QueueManagementViewControllers.swift | 11 + .../QueueManagementViewModels.swift | 29 + .../Models/ScenicPermissionModels.swift | 8 + .../ScenicPermissionViewControllers.swift | 57 +- .../Models/ScenicSettlementModels.swift | 1 + .../ScenicSettlementViewControllers.swift | 21 +- .../ScheduleViewControllers.swift | 16 + .../ViewModels/ScheduleViewModels.swift | 2 + .../Statistics/Models/StatisticsModels.swift | 8 +- .../Features/Statistics/Statistics.md | 8 +- .../StatisticsViewController.swift | 368 +++++++--- .../ViewControllers/TaskViewControllers.swift | 19 + .../ViewModels/TaskCreateViewModel.swift | 1 + .../Features/Wallet/Models/WalletModels.swift | 18 + .../WalletViewControllers.swift | 13 + .../WithdrawalAuditViewController.swift | 7 + suixinkan_ios/Info.plist | 14 + suixinkan_ios/SceneDelegate.swift | 7 + .../NavigationRouterTests.swift | 1 + .../PilotCertificationTests.swift | 1 + .../PushNotificationTests.swift | 1 + 功能同步Checklist.md | 217 +++--- 124 files changed, 5195 insertions(+), 1536 deletions(-) create mode 100644 Scripts/add_swift_doc_comments.py create mode 100644 Scripts/polish_swift_doc_comments.py create mode 100644 suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png create mode 100644 suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png create mode 100644 suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json create mode 100644 suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png create mode 100644 suixinkan_ios/Core/Map/Map.md create mode 100644 suixinkan_ios/Core/Map/OperatingAreaMapView.swift create mode 100644 suixinkan_ios/Core/Map/PunchPointMapPickerView.swift create mode 100644 suixinkan_ios/Core/UIKit/ListDiffableSupport.swift diff --git a/AGENTS.md b/AGENTS.md index 525aa3b..1bbd9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,12 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、 - View 层只负责展示与事件转发,业务逻辑放在 ViewModel - ViewModel 不直接持有 UIView / UIViewController 引用 +### Swift 并发 + +- 工程已将编译选项 **`SWIFT_DEFAULT_ACTOR_ISOLATION`** 设置为 **`nonisolated`**(见 Xcode Build Settings) +- 即类型与方法**默认不**隔离到 `@MainActor`;需要主线程/UI 相关逻辑时,再显式标注 `@MainActor` 或使用 `MainActor.assumeIsolated` / `await MainActor.run` +- 仅在确实需要脱离默认隔离(如 delegate 回调、静态工具方法)时再显式写 `nonisolated`,避免冗余标注 + ### 注释 - 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为 @@ -117,5 +123,6 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、 - 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md) - 参考工程:`../suixinkan_ios_new`(SwiftUI) +- 高德 Key:在 `suixinkan_ios/Info.plist` 的 `AMapAPIKey` 填入控制台 Key(真机地图生效) - 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build` - 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'` diff --git a/Podfile b/Podfile index 1ac2c29..8d9923c 100644 --- a/Podfile +++ b/Podfile @@ -6,15 +6,90 @@ target 'suixinkan_ios' do pod 'SnapKit', '~> 5.7' pod 'Kingfisher', '~> 8.0' + # 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离 + pod 'AMap3DMap-NO-IDFA', '~> 11.1' + pod 'AMapSearch-NO-IDFA', '~> 9.7' + pod 'AMapLocation-NO-IDFA', '~> 2.11' + target 'suixinkan_iosTests' do inherit! :search_paths end end +SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish + -ObjC -l"c++" -l"z" + -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" + -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" + -framework "OpenGLES" -framework "QuartzCore" -framework "Security" + -framework "SystemConfiguration" +FLAGS + post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' end end + + %w[Pods-suixinkan_ios Pods-suixinkan_iosTests].each do |target_name| + support_dir = File.join(installer.sandbox.root, 'Target Support Files', target_name) + Dir.glob(File.join(support_dir, '*.xcconfig')).each do |path| + content = File.read(path) + content.gsub!("EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64\n", '') + + unless content.include?('OTHER_LDFLAGS[sdk=iphonesimulator*]') + content << "\nOTHER_LDFLAGS[sdk=iphonesimulator*] = #{SIMULATOR_OTHER_LDFLAGS}\n" + content << "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited)\n" + end + + File.write(path, content) + end + end + + installer.pods_project.targets.each do |target| + next unless target.name.start_with?('AMap') + + target.build_configurations.each do |config| + config.build_settings['SUPPORTED_PLATFORMS'] = 'iphoneos' + end + end + + resources_script = File.join( + installer.sandbox.root, + 'Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh' + ) + if File.exist?(resources_script) + content = File.read(resources_script) + unless content.include?('SKIP_AMAP_RESOURCES_FOR_SIMULATOR') + content.sub!( + "RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt", + <<~SHELL + # SKIP_AMAP_RESOURCES_FOR_SIMULATOR + if [ "${EFFECTIVE_PLATFORM_NAME}" = "-iphonesimulator" ]; then + exit 0 + fi + + RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt + SHELL + ) + File.write(resources_script, content) + end + end + + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.user_project.native_targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '' + config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' + + next unless target.name == 'suixinkan_ios' + + existing = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] || '$(inherited)' + unless existing.to_s.include?('AMAP_ENABLED') + config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] = "#{existing} AMAP_ENABLED" + end + end + end + aggregate_target.user_project.save + end end diff --git a/Podfile.lock b/Podfile.lock index 58a8163..2711869 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,20 +1,38 @@ PODS: + - AMap3DMap-NO-IDFA (11.2.000): + - AMapFoundation-NO-IDFA (>= 1.9.0) + - AMapFoundation-NO-IDFA (1.9.0) + - AMapLocation-NO-IDFA (2.12.0): + - AMapFoundation-NO-IDFA (>= 1.9.0) + - AMapSearch-NO-IDFA (9.8.0): + - AMapFoundation-NO-IDFA (>= 1.9.0) - Kingfisher (8.10.0) - SnapKit (5.7.1) DEPENDENCIES: + - AMap3DMap-NO-IDFA (~> 11.1) + - AMapLocation-NO-IDFA (~> 2.11) + - AMapSearch-NO-IDFA (~> 9.7) - Kingfisher (~> 8.0) - SnapKit (~> 5.7) SPEC REPOS: trunk: + - AMap3DMap-NO-IDFA + - AMapFoundation-NO-IDFA + - AMapLocation-NO-IDFA + - AMapSearch-NO-IDFA - Kingfisher - SnapKit SPEC CHECKSUMS: + AMap3DMap-NO-IDFA: f53ee0cb33db83c1a08856b5995e53a0c2096158 + AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e + AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18 + AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a -PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c +PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c COCOAPODS: 1.16.2 diff --git a/Scripts/add_swift_doc_comments.py b/Scripts/add_swift_doc_comments.py new file mode 100644 index 0000000..00f18f9 --- /dev/null +++ b/Scripts/add_swift_doc_comments.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""为 Swift 源码中缺少 /// 的类型与方法补充中文文档注释。""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "suixinkan_ios" + +SKIP_DIRS = {"Pods", ".build", "DerivedData"} + +TYPE_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+)*)" + r"(?:(?P(?:final|private|fileprivate|public|internal|open)\s+)*)" + r"(?Pclass|struct|enum|protocol|actor)\s+(?P\w+)" +) + +FUNC_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+|@objc\s+)*)" + r"(?:(?P(?:override|private|fileprivate|public|internal|static|class|mutating|nonisolated)\s+)*)" + r"func\s+(?P\w+)" +) + +INIT_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@available\([^)]+\)\s+)*)" + r"(?:(?:override|required|convenience|private|fileprivate|public|internal)\s+)*" + r"init(\?|\()" +) + +METHOD_DOCS: dict[str, str] = { + "viewDidLoad": "视图加载完成后的 UI 初始化与数据绑定。", + "viewWillAppear": "视图即将展示,刷新可见状态。", + "viewDidAppear": "视图已展示,执行需等待布局完成的逻辑。", + "viewWillDisappear": "视图即将消失,保存或清理临时状态。", + "viewDidDisappear": "视图已消失。", + "viewDidLayoutSubviews": "子视图布局完成后调整依赖 frame 的 UI。", + "deinit": "释放资源并解除绑定。", + "numberOfSections": "返回列表 section 数量。", + "numberOfRowsInSection": "返回指定 section 的行数。", + "cellForRowAt": "配置并返回指定 indexPath 的 Cell。", + "didSelectRowAt": "处理行选中事件。", + "heightForRowAt": "返回指定行高度。", + "titleForHeaderInSection": "返回 section 标题。", + "numberOfItemsInSection": "返回指定 section 的 item 数量。", + "cellForItemAt": "配置并返回指定 indexPath 的 Collection Cell。", + "didSelectItemAt": "处理 item 选中事件。", + "sizeForItemAt": "返回 item 尺寸。", + "makeUIViewController": "创建并返回对应路由的 ViewController。", + "updateUIViewController": "更新 ViewController 状态。", + "encode": "编码为可持久化或传输的数据。", + "decode": "从外部数据解码为模型实例。", + "hash": "计算哈希值,用于集合与 Diffable 标识。", + "==": "判断两个实例是否相等。", +} + +TYPE_SUFFIX_DOCS: list[tuple[str, str]] = [ + ("ViewController", "页面控制器,负责 UI 展示与用户交互。"), + ("ViewModel", "视图模型,负责业务逻辑与状态管理。"), + ("TableViewCell", "列表 Cell,负责单行内容展示。"), + ("CollectionViewCell", "集合视图 Cell,负责单项内容展示。"), + ("Cell", "列表或网格 Cell,负责单项内容展示。"), + ("Serving", "服务协议,定义模块对外能力。"), + ("API", "网络接口封装。"), + ("Store", "本地持久化或内存存储。"), + ("Provider", "能力提供者,封装外部依赖。"), + ("Coordinator", "流程协调器,串联多步业务。"), + ("Router", "路由跳转封装。"), + ("Manager", "管理器,负责模块级生命周期与调度。"), + ("Service", "服务层,封装可复用业务能力。"), + ("Error", "错误类型定义。"), + ("State", "状态实体。"), + ("Context", "上下文,持有跨页面共享状态。"), + ("Delegate", "代理协议或实现。"), + ("DataSource", "数据源协议或实现。"), + ("Loader", "加载器,负责异步拉取与组装数据。"), + ("Runtime", "运行时调度器,负责后台任务与状态同步。"), + ("Client", "客户端封装,负责与外部服务通信。"), + ("Factory", "工厂,负责按路由或参数创建 ViewController。"), + ("Driver", "驱动器,供 UI 测试或自动化场景使用。"), +] + + +def preceding_doc_index(lines: list[str], index: int) -> int | None: + """向上查找最近的 /// 或阻断性声明,返回 /// 行号。""" + j = index - 1 + while j >= 0: + stripped = lines[j].strip() + if stripped == "": + j -= 1 + continue + if stripped.startswith("///"): + return j + if stripped.startswith("@") or stripped.startswith("// MARK:"): + j -= 1 + continue + break + return None + + +def has_doc_comment(lines: list[str], index: int) -> bool: + return preceding_doc_index(lines, index) is not None + + +def camel_to_chinese_hint(name: str) -> str: + if name in METHOD_DOCS: + return METHOD_DOCS[name] + if name.startswith("handle"): + return f"处理{name[6:]}相关事件。" + if name.startswith("load"): + return f"加载{name[4:]}数据。" + if name.startswith("fetch"): + return f"请求{name[5:]}数据。" + if name.startswith("reload"): + return f"刷新{name[6:]}。" + if name.startswith("update"): + return f"更新{name[6:]}状态。" + if name.startswith("configure"): + return f"配置{name[9:]}展示内容。" + if name.startswith("setup"): + return f"初始化{name[5:]}相关 UI 或状态。" + if name.startswith("bind"): + return f"绑定{name[4:]}回调或数据。" + if name.startswith("make"): + return f"创建{name[4:]}实例。" + if name.startswith("wire"): + return f"连接{name[4:]}与 UI 刷新逻辑。" + if name.startswith("sync"): + return f"同步{name[4:]}状态。" + if name.startswith("register"): + return f"注册{name[8:]}。" + if name.startswith("did"): + return f"{name} 回调处理。" + if name.startswith("will"): + return f"{name} 回调处理。" + if name.startswith("on"): + return f"响应{name[2:]}事件。" + if name.startswith("is") or name.startswith("has"): + return f"判断{name}条件。" + if name.startswith("validate"): + return f"校验{name[8:]}输入或状态。" + if name.startswith("submit"): + return f"提交{name[6:]}。" + if name.startswith("show"): + return f"展示{name[4:]}。" + if name.startswith("hide"): + return f"隐藏{name[4:]}。" + if name.startswith("present"): + return f"弹出{name[7:]}页面。" + if name.startswith("push"): + return f"Push {name[4:]}页面。" + if name.startswith("pop"): + return f"Pop {name[3:]}页面。" + if name.startswith("start"): + return f"启动{name[5:]}流程。" + if name.startswith("stop"): + return f"停止{name[4:]}流程。" + if name.startswith("reset"): + return f"重置{name[5:]}状态。" + if name.startswith("parse"): + return f"解析{name[5:]}数据。" + if name.startswith("normalize"): + return f"规范化{name[9:]}格式。" + if name.startswith("refresh"): + return f"刷新{name[7:]}展示。" + if name.startswith("attach"): + return f"挂载{name[6:]}到目标视图层级。" + if name.startswith("mount"): + return f"挂载{name[5:]}子控制器。" + if name.startswith("run"): + return f"执行{name[3:]}循环或任务。" + if name.startswith("poll"): + return f"轮询{name[4:]}数据。" + if name.startswith("resume"): + return f"恢复{name[6:]}流程。" + if name.startswith("enqueue"): + return f"入队等待{name[7:]}处理。" + if name.startswith("route"): + return f"根据{name[5:]}执行路由跳转。" + if name.startswith("navigate"): + return f"导航至{name[8:]}页面。" + if name == "init": + return "初始化实例。" + return f"{name} 方法实现。" + + +def type_doc(name: str, kind: str) -> str: + for suffix, desc in TYPE_SUFFIX_DOCS: + if name.endswith(suffix): + prefix = name[: -len(suffix)] + if prefix: + return f"{prefix}{desc}" + return desc + kind_map = { + "enum": "枚举,定义相关常量或状态。", + "struct": "结构体,封装数据实体。", + "protocol": "协议,定义能力约束。", + "actor": "Actor,保证并发安全的状态访问。", + "class": "类,封装业务逻辑或 UI 组件。", + } + return kind_map.get(kind, f"{name} 类型定义。") + + +def should_skip_init(line: str) -> bool: + return "unavailable" in line or "init?(coder" in line + + +def should_skip_func(name: str) -> bool: + return name in {"body", "callAsFunction", "previewLayout"} + + +def process_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + output: list[str] = [] + changed = False + i = 0 + + while i < len(lines): + line = lines[i] + stripped = line.rstrip("\n") + + type_match = TYPE_DECL.match(stripped) + func_match = FUNC_DECL.match(stripped) + init_match = INIT_DECL.match(stripped) + + if type_match and not has_doc_comment(lines, i): + indent = type_match.group("indent") + name = type_match.group("name") + kind = type_match.group("kind") + output.append(f"{indent}/// {type_doc(name, kind)}\n") + changed = True + + elif func_match and not has_doc_comment(lines, i): + name = func_match.group("name") + if not should_skip_func(name): + indent = func_match.group("indent") + output.append(f"{indent}/// {camel_to_chinese_hint(name)}\n") + changed = True + + elif init_match and not has_doc_comment(lines, i) and not should_skip_init(stripped): + indent = init_match.group("indent") + output.append(f"{indent}/// 初始化实例。\n") + changed = True + + output.append(line) + i += 1 + + if changed: + path.write_text("".join(output), encoding="utf-8") + return changed + + +def main() -> int: + target = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT + changed_files: list[str] = [] + + for _ in range(3): + round_changed: list[str] = [] + for path in sorted(target.rglob("*.swift")): + if any(part in SKIP_DIRS for part in path.parts): + continue + if process_file(path): + round_changed.append(str(path.relative_to(ROOT.parent))) + changed_files = round_changed + if not round_changed: + break + + print(f"Updated {len(changed_files)} files in last pass") + for file in changed_files: + print(f" - {file}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/polish_swift_doc_comments.py b/Scripts/polish_swift_doc_comments.py new file mode 100644 index 0000000..e511525 --- /dev/null +++ b/Scripts/polish_swift_doc_comments.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""修正自动生成的低质量 Swift 文档注释。""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "suixinkan_ios" + +METHOD_OVERRIDES: dict[str, str] = { + "startIfNeeded": "条件满足时启动轮询任务。", + "runLoop": "按间隔持续轮询队列数据。", + "pollOnce": "执行一次队列数据拉取与播报判断。", + "pollIntervalSeconds": "根据前后台状态返回轮询间隔秒数。", + "selectedSpotId": "读取用户当前选中的打卡点 ID。", + "resetSnapshot": "重置播报状态与上次轮询快照。", + "beginBackgroundTask": "申请后台任务,保证进入后台时仍可短时轮询。", + "endBackgroundTask": "结束后台任务。", + "resumePending": "恢复所有等待播报结束的 continuation。", + "enqueue": "将语音文本加入播报队列,可选择替换待播内容。", + "registerNavigationBridge": "向 UIKit 导航桥注册当前 Tab 的 NavigationController。", + "syncFromRouterPath": "根据 Router 路径深度补齐或回退导航栈。", + "syncRouterPathFromStack": "导航栈变化后裁剪 Router 路径,保持双向同步。", + "observeContextChanges": "监听权限与账号上下文变化并刷新菜单。", + "rebuildMenus": "按当前角色权限重建首页菜单与常用应用。", + "menuItem": "按 URI 解析并返回可用菜单项。", + "startCountdownTimerIfNeeded": "在线状态下启动位置上报倒计时。", + "scenicTapped": "点击景区名称,跳转景区选择页。", + "reminderTapped": "选择位置上报提前提醒时间。", + "locationReportTapped": "跳转位置上报页面。", + "paymentTapped": "跳转立即收款页面。", + "taskCreateTapped": "跳转提交任务页面。", + "onlineTapped": "切换在线/离线状态并更新倒计时。", + "setupTopBar": "搭建首页顶部景区选择栏。", + "setupTableView": "配置首页分组列表布局与注册 Cell。", + "makeStatusCard": "构建在线状态与倒计时卡片。", + "makeLocationCard": "构建位置上报提醒卡片。", + "makeStoreCard": "构建门店信息卡片。", + "makeQuickActionsRow": "构建快捷操作按钮行。", + "configureViews": "初始化子视图与约束。", + "refreshPresentation": "根据当前状态刷新展示或隐藏动画。", + "showBanner": "展示 Toast 横幅动画。", + "hideBanner": "隐藏 Toast 横幅动画。", + "loadAnimation": "加载 Lottie 动画资源。", + "route": "解析推送载荷并执行路由跳转。", + "navigateHomeRoute": "切换至首页 Tab 并 Push 目标路由。", + "hexString": "将二进制数据转为十六进制字符串。", + "receiveLoop": "持续接收 WebSocket 消息并分发处理。", + "stringValue": "安全地将任意值转为字符串。", + "homeRouteURI": "将首页路由编码为 URI 字符串。", + "make": "按路由参数创建 ViewController。", + "fail": "记录校验失败并返回 false。", + "normalizedError": "将后端错误信息归一化为用户可读中文。", + "amountText": "格式化金额展示文本。", + "summaryCard": "创建统计摘要卡片视图。", + "selectPeriod": "切换统计周期并重新加载数据。", + "reload": "加载或刷新页面数据。", + "loadMore": "加载下一页列表数据。", + "updateFilterTitle": "更新筛选条件标题展示。", + "updateSummary": "刷新钱包摘要区域。", + "setupHeader": "搭建页面头部区域。", + "updateTitle": "刷新导航或页面标题。", + "setupFormHeader": "搭建表单页头部说明区域。", + "wireViewModel": "绑定 ViewModel 变更回调并触发列表刷新。", + "bindViewModel": "绑定 ViewModel 数据变更并刷新 UI。", + "handleRefresh": "下拉刷新触发,重新加载页面数据。", + "mountChild": "按登录阶段挂载对应子控制器。", + "updateScenicTitle": "刷新顶部景区名称展示。", +} + +LINE_REPLACEMENTS: dict[str, str] = { + "/// speechSynthesizer 方法实现。": "/// 语音播报生命周期回调。", + "/// 处理相关事件。": "/// 更新轮询结果并根据队列变化触发语音播报。", + "/// tableView 方法实现。": "/// UITableView 数据源或代理回调。", + "/// 启动IfNeeded流程。": "/// 条件满足时启动轮询任务。", + "/// 执行Loop循环或任务。": "/// 按间隔持续轮询队列数据。", + "/// 轮询Once数据。": "/// 执行一次队列数据拉取与播报判断。", + "/// 轮询IntervalSeconds数据。": "/// 根据前后台状态返回轮询间隔秒数。", + "/// 重置Snapshot状态。": "/// 重置播报状态与上次轮询快照。", + "/// 恢复Pending流程。": "/// 恢复所有等待播报结束的 continuation。", + "/// 更新ScenicTitle状态。": "/// 刷新顶部景区名称展示。", + "/// 绑定ViewModel回调或数据。": "/// 绑定 ViewModel 数据变更并刷新 UI。", + "/// 响应lineTapped事件。": "/// 切换在线/离线状态并更新倒计时。", + "/// observeContextChanges 方法实现。": "/// 监听权限与账号上下文变化并刷新菜单。", + "/// rebuildMenus 方法实现。": "/// 按当前角色权限重建首页菜单与常用应用。", + "/// menuItem 方法实现。": "/// 按 URI 解析并返回可用菜单项。", + "/// 启动CountdownTimerIfNeeded流程。": "/// 在线状态下启动位置上报倒计时。", + "/// scenicTapped 方法实现。": "/// 点击景区名称,跳转景区选择页。", + "/// reminderTapped 方法实现。": "/// 选择位置上报提前提醒时间。", + "/// locationReportTapped 方法实现。": "/// 跳转位置上报页面。", + "/// paymentTapped 方法实现。": "/// 跳转立即收款页面。", + "/// taskCreateTapped 方法实现。": "/// 跳转提交任务页面。", + "/// selectedSpotId 方法实现。": "/// 读取用户当前选中的打卡点 ID。", + "/// beginBackgroundTask 方法实现。": "/// 申请后台任务,保证进入后台时仍可短时轮询。", + "/// endBackgroundTask 方法实现。": "/// 结束后台任务。", +} + +METHOD_LINE = re.compile(r"^(?P\s*)/// (?P\w+) 方法实现。\s*$") +FUNC_LINE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:override\s+|private\s+|static\s+)*func\s+(?P\w+)") + +WORD_HINTS: dict[str, str] = { + "Tapped": "点击", + "Setup": "初始化", + "Configure": "配置", + "Update": "更新", + "Reload": "刷新", + "Load": "加载", + "Fetch": "请求", + "Create": "创建", + "Delete": "删除", + "Clear": "清空", + "Copy": "复制", + "Show": "展示", + "Hide": "隐藏", + "Start": "启动", + "Stop": "停止", + "Cancel": "取消", + "Confirm": "确认", + "Apply": "提交", + "Parse": "解析", + "Format": "格式化", + "Decode": "解码", + "Encode": "编码", + "Validate": "校验", + "Submit": "提交", + "Toggle": "切换", + "Refresh": "刷新", + "Build": "构建", + "Make": "创建", + "Present": "弹出", + "Dismiss": "关闭", + "Select": "选择", + "Filter": "筛选", + "Handle": "处理", + "Check": "检查", + "Request": "请求", + "Register": "注册", + "Bind": "绑定", + "Sync": "同步", + "Mount": "挂载", + "Embed": "嵌入", + "Generate": "生成", + "Edit": "编辑", + "Add": "添加", + "Reset": "重置", + "Save": "保存", + "Open": "打开", + "Close": "关闭", + "Login": "登录", + "Logout": "登出", + "Complete": "完成", + "Continue": "继续", + "Consume": "消费", + "Dedupe": "去重", + "Scanner": "扫码", + "Camera": "相机", + "Permission": "权限", + "Avatar": "头像", + "Header": "头部", + "Card": "卡片", + "Row": "行", + "Field": "字段", + "Timer": "计时器", + "Countdown": "倒计时", + "Queue": "排队", + "Order": "订单", + "Task": "任务", + "Project": "项目", + "Scenic": "景区", + "Store": "门店", + "Wallet": "钱包", + "Payment": "支付", + "Album": "相册", + "Schedule": "排班", + "Invite": "邀请", + "Message": "消息", + "Location": "位置", + "Live": "直播", + "Flyer": "飞手", + "Punch": "打卡", + "Detail": "详情", + "List": "列表", + "Summary": "摘要", + "Amount": "金额", + "Code": "验证码", + "QR": "二维码", + "URL": "链接", + "Text": "文本", + "Title": "标题", + "Form": "表单", + "Keyboard": "键盘", + "Business": "营业", + "Time": "时间", + "Date": "日期", + "Folder": "文件夹", + "File": "文件", + "Broadcast": "播报", + "Voice": "语音", + "Background": "后台", + "Foreground": "前台", + "Remote": "远程", + "Scan": "扫码", + "Verify": "验证", + "Collection": "集合", + "Table": "列表", + "View": "视图", + "Model": "模型", + "Data": "数据", + "Payload": "载荷", + "Snapshot": "快照", + "Runtime": "运行时", + "Settings": "设置", + "Menu": "菜单", + "Route": "路由", + "Navigation": "导航", + "Tab": "Tab", + "Badge": "角标", + "Filter": "筛选", + "Search": "搜索", + "Upload": "上传", + "Download": "下载", + "Image": "图片", + "Video": "视频", + "Audio": "音频", + "Map": "地图", + "Spot": "打卡点", + "Ticket": "票号", + "Stats": "统计", + "Report": "上报", + "Audit": "审核", + "Settlement": "结算", + "Withdrawal": "提现", + "Deposit": "押金", + "Refund": "退款", + "WriteOff": "核销", + "Pilot": "飞手", + "Certification": "认证", + "RealName": "实名", + "Account": "账号", + "Switch": "切换", + "Profile": "个人中心", + "Auth": "认证", + "Session": "会话", + "Token": "令牌", + "Push": "推送", + "Notification": "通知", + "Socket": "WebSocket", + "Client": "客户端", + "Provider": "提供者", + "Factory": "工厂", + "Helper": "工具", + "Extension": "扩展", + "IfNeeded": "(按需)", + "And": "并", + "Or": "或", + "From": "从", + "To": "至", + "For": "用于", + "With": "附带", + "Without": "不带", + "All": "全部", + "Current": "当前", + "Selected": "选中", + "Pending": "待处理", + "Active": "活跃", + "Empty": "空", + "Default": "默认", + "Custom": "自定义", + "Local": "本地", + "Remote": "远程", + "Legacy": "兼容旧版", + "Normalized": "规范化", + "Coerced": "强制转换", + "Lossy": "宽松", + "Deduplicated": "去重", + "Sorted": "排序", + "Changed": "变更", + "Interval": "间隔", + "Threshold": "阈值", + "Positive": "正数", + "Decimal": "小数", + "Int": "整数", + "String": "字符串", + "Bool": "布尔", + "Value": "值", + "Object": "对象", + "JSON": "JSON", + "LatLng": "经纬度", + "LngLat": "经纬度", + "Pair": "坐标对", + "LooksLike": "判断是否符合", + "Fill": "填充", + "Cycle": "循环切换", + "ApplyViewModel": "应用 ViewModel 状态", + "Wire": "连接", + "Dispose": "释放订阅", + "Embed": "嵌入", + "Labeled": "带标签", + "LongValid": "长期有效", + "ClearListAndDetail": "清空列表与详情", + "ClearQueueData": "清空排队数据", + "ClearMessages": "清空消息", + "ClearFolders": "清空文件夹", + "CheckPermissionAndStart": "检查权限并启动", + "ConsumePendingRemoteCalledTickets": "消费待处理的远程叫号", + "ConsumePendingScanCodeIfNeeded": "按需消费待处理扫码结果", + "FillFormIfNeeded": "按需回填表单", + "CopyOrderNumber": "复制订单号", + "CopyDownloadLink": "复制下载链接", + "CopyCode": "复制验证码", + "CopyURL": "复制链接", + "ConfirmVerify": "确认验证码", + "CreatePunchPoint": "创建打卡点", + "CreateTask": "创建任务", + "CreateProject": "创建项目", + "AddSchedule": "添加排班", + "ApplyAmount": "提交金额", + "BusinessTime": "营业时间", + "BusinessTimePayload": "构建营业时间请求体", + "AvatarPlaceholder": "生成头像占位图", + "FormatQueueTime": "格式化排队时间", + "FromJSONObject": "从 JSON 对象解析", + "GenerateQRCode": "生成二维码", + "Int64Value": "解析 Int64 值", + "IntValue": "解析 Int 值", + "DecimalValue": "解析小数值", + "EmptyToZero": "空值转零", + "FileType": "解析文件类型", + "DecodeLossyBool": "宽松解码布尔值", + "DecodeLossyDouble": "宽松解码浮点数", + "DecodeLossyInt": "宽松解码整数", + "DecodeLossyString": "宽松解码字符串", + "LiveDecodeLossyInt": "直播模块宽松解码整数", + "LiveDecodeLossyString": "直播模块宽松解码字符串", + "CoercedBroadcastInterval": "规范化播报间隔", + "CoercedCountdownThreshold": "规范化倒计时阈值", + "LegacyPositiveInt": "兼容旧版正整数解析", + "DeduplicatedAndSorted": "去重并排序", + "CompleteLogin": "完成登录流程", + "LoginTapped": "点击登录按钮", + "LogoutTapped": "点击登出", + "CancelTapped": "点击取消", + "ConfirmTapped": "点击确认", + "CloseTapped": "点击关闭", + "ContinueTapped": "点击继续", + "EditTapped": "点击编辑", + "DetailTapped": "点击详情", +} + + +def split_camel(name: str) -> list[str]: + parts = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name).split() + return parts + + +def describe_method(name: str) -> str: + if name in METHOD_OVERRIDES: + return METHOD_OVERRIDES[name] + if name in WORD_HINTS: + return f"{WORD_HINTS[name]}。" + + # 尝试从最长前缀/后缀匹配组合词 + if name.endswith("Tapped"): + base = name[:-6] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"点击{hint}的处理逻辑。" + if name.endswith("IfNeeded"): + base = name[:-8] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"按需{hint}。" + if name.startswith("make"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"创建{hint}。" + if name.startswith("setup"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"初始化{hint}。" + if name.startswith("update"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"更新{hint}。" + if name.startswith("load"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"加载{hint}。" + if name.startswith("clear"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"清空{hint}。" + if name.startswith("copy"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"复制{hint}。" + if name.startswith("create"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"创建{hint}。" + if name.startswith("decode"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"解码{hint}。" + if name.startswith("format"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"格式化{hint}。" + if name.startswith("live"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"直播{hint}相关逻辑。" + if name.startswith("flyer"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"飞手{hint}相关逻辑。" + + parts = split_camel(name) + if len(parts) == 1: + return f"{name} 业务逻辑。" + hint = "".join(WORD_HINTS.get(p, p) for p in parts) + return f"{hint}相关逻辑。" + + +def process_file(path: Path) -> bool: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + changed = False + + for i, line in enumerate(lines): + stripped = line.rstrip("\n") + if stripped in LINE_REPLACEMENTS: + indent = re.match(r"^(\s*)", line).group(1) + output.append(f"{indent}{LINE_REPLACEMENTS[stripped]}\n") + changed = True + continue + + match = METHOD_LINE.match(stripped) + if match: + indent = match.group("indent") + name = match.group("name") + output.append(f"{indent}/// {describe_method(name)}\n") + changed = True + continue + + if stripped.startswith("/// ") and i + 1 < len(lines): + func_match = FUNC_LINE.match(lines[i + 1]) + if func_match: + name = func_match.group("name") + if name in METHOD_OVERRIDES and ("方法实现" in stripped or name in stripped): + indent = re.match(r"^(\s*)", line).group(1) + output.append(f"{indent}/// {METHOD_OVERRIDES[name]}\n") + changed = True + continue + + output.append(line) + + if changed: + path.write_text("".join(output), encoding="utf-8") + return changed + + +def main() -> int: + target = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT + count = sum(1 for path in sorted(target.rglob("*.swift")) if process_file(path)) + print(f"Polished {count} files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/suixinkan_ios.xcodeproj/project.pbxproj b/suixinkan_ios.xcodeproj/project.pbxproj index ebbea9d..b9bbafd 100644 --- a/suixinkan_ios.xcodeproj/project.pbxproj +++ b/suixinkan_ios.xcodeproj/project.pbxproj @@ -134,6 +134,7 @@ 9331714B2FEE49BE00240726 /* Frameworks */, 9331714C2FEE49BE00240726 /* Resources */, FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */, + 367681C4AC079C922280F774 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -234,6 +235,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 367681C4AC079C922280F774 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -337,6 +359,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -356,8 +379,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED"; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -375,6 +399,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -394,8 +419,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED"; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -533,6 +559,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( @@ -549,7 +576,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -568,6 +595,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( @@ -584,7 +612,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; diff --git a/suixinkan_ios/App/AMapBootstrap.swift b/suixinkan_ios/App/AMapBootstrap.swift index 1206486..7b4c5f4 100644 --- a/suixinkan_ios/App/AMapBootstrap.swift +++ b/suixinkan_ios/App/AMapBootstrap.swift @@ -13,8 +13,9 @@ import AMapLocationKit import AMapSearchKit import MAMapKit +/// 高德地图 SDK 初始化封装,真机构建时配置 Key 与隐私合规。 enum AMapBootstrap { - /// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。 + /// 在 App 启动时调用一次,配置 API Key 并声明隐私政策已展示与同意。 static func configure(apiKey: String) { guard !apiKey.isEmpty else { return } @@ -30,7 +31,9 @@ enum AMapBootstrap { } } #else +/// 模拟器构建占位,不链接高德 SDK。 enum AMapBootstrap { + /// 空实现,保持调用方编译通过。 static func configure(apiKey: String) {} } #endif diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift index 2a683b2..cccc9b2 100644 --- a/suixinkan_ios/App/AppServices.swift +++ b/suixinkan_ios/App/AppServices.swift @@ -67,6 +67,7 @@ final class AppServices { accountContext.profile?.userId } + /// 初始化实例。 private init() { let client = APIClient() let tokenStore = SessionTokenStore() diff --git a/suixinkan_ios/App/AppUITestRouteDriver.swift b/suixinkan_ios/App/AppUITestRouteDriver.swift index fd1bbe3..913d965 100644 --- a/suixinkan_ios/App/AppUITestRouteDriver.swift +++ b/suixinkan_ios/App/AppUITestRouteDriver.swift @@ -8,7 +8,7 @@ #if DEBUG import UIKit -/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。 +/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开 Tab 切换与 push 链路。 @MainActor enum AppUITestRouteDriver { private static var didApply = false diff --git a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift index cbe8ba6..3c91fd6 100644 --- a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift +++ b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift @@ -164,6 +164,7 @@ enum AppRouteViewControllerFactory { } } + /// 将首页路由编码为 URI 字符串。 private static func homeRouteURI(_ route: HomeRoute) -> String { if case let .modulePlaceholder(uri, _) = route { return uri @@ -175,6 +176,7 @@ enum AppRouteViewControllerFactory { @MainActor /// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。 enum HomeRouteViewControllerFactory { + /// 创建实例。 static func make(for route: HomeRoute) -> UIViewController { AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) } diff --git a/suixinkan_ios/App/Navigation/AppTab.swift b/suixinkan_ios/App/Navigation/AppTab.swift index f02307f..2542c50 100644 --- a/suixinkan_ios/App/Navigation/AppTab.swift +++ b/suixinkan_ios/App/Navigation/AppTab.swift @@ -29,19 +29,47 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable { } } - var systemImage: String { + /// 选中态 TabBar 图标资源名。 + var selectedImageName: String { switch self { case .home: - "house" + "TabHomeSelected" case .orders: - "doc.text" + "TabOrderSelected" case .statistics: - "chart.bar" + "TabDataSelected" case .profile: - "person" + "TabProfileSelected" } } + /// 未选中态 TabBar 图标资源名。 + var unselectedImageName: String { + switch self { + case .home: + "TabHomeUnselected" + case .orders: + "TabOrderUnselected" + case .statistics: + "TabDataUnselected" + case .profile: + "TabProfileUnselected" + } + } + + /// 构建系统 TabBarItem,供 `UITabBarController` 使用。 + func makeTabBarItem() -> UITabBarItem { + let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal) + let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal) + let item = UITabBarItem( + title: title, + image: unselectedImage, + selectedImage: selectedImage + ) + item.accessibilityIdentifier = "main.tab.\(rawValue)" + return item + } + /// 构建 Tab 根页面 ViewController。 func makeRootViewController(services: AppServices) -> UIViewController { switch self { diff --git a/suixinkan_ios/App/Navigation/NavigationRouter.swift b/suixinkan_ios/App/Navigation/NavigationRouter.swift index 998ba00..e4aa5ee 100644 --- a/suixinkan_ios/App/Navigation/NavigationRouter.swift +++ b/suixinkan_ios/App/Navigation/NavigationRouter.swift @@ -53,6 +53,7 @@ final class AppRouter { private let routers: [AppTab: RouterPath] + /// 初始化实例。 init() { var builtRouters: [AppTab: RouterPath] = [:] for tab in AppTab.allCases { diff --git a/suixinkan_ios/App/Navigation/TabNavigationController.swift b/suixinkan_ios/App/Navigation/TabNavigationController.swift index 1f49cf1..35ca5c0 100644 --- a/suixinkan_ios/App/Navigation/TabNavigationController.swift +++ b/suixinkan_ios/App/Navigation/TabNavigationController.swift @@ -13,8 +13,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl private let services: AppServices private var isSyncingStack = false - weak var tabBarHost: MainTabBarController? - + /// 初始化实例。 init(tab: AppTab, services: AppServices) { self.appTab = tab self.services = services @@ -43,15 +42,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl pushViewController(viewController, animated: animated) } - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let isRoot = viewController === viewControllers.first - tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated) - } - + /// navigationController相关逻辑。 func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, @@ -60,6 +51,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl syncRouterPathFromStack() } + /// 注册NavigationBridge。 private func registerNavigationBridge() { switch appTab { case .home: @@ -71,6 +63,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl } } + /// 同步FromRouterPath状态。 private func syncFromRouterPath() { guard !isSyncingStack else { return } @@ -79,6 +72,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl let currentDepth = max(viewControllers.count - 1, 0) if targetDepth > currentDepth { + // Router 路径变深:按路径依次 push 缺失页面 for index in currentDepth.. UIViewController { let controller = UIViewController() controller.view.backgroundColor = .systemBackground diff --git a/suixinkan_ios/AppDelegate.swift b/suixinkan_ios/AppDelegate.swift index b232f66..3c9e034 100644 --- a/suixinkan_ios/AppDelegate.swift +++ b/suixinkan_ios/AppDelegate.swift @@ -2,35 +2,62 @@ // AppDelegate.swift // suixinkan_ios // -// Created by hanqiu on 2026/6/26. -// import UIKit +/// 应用入口,承接 APNs 回调并在启动时初始化高德 SDK。 @main class AppDelegate: UIResponder, UIApplicationDelegate { - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + /// 应用启动:重置 UI 测试状态并配置高德 Key。 + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + AppUITestLaunchState.resetIfNeeded() + if let key = Bundle.main.object(forInfoDictionaryKey: "AMapAPIKey") as? String { + AMapBootstrap.configure(apiKey: key) + } return true } - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Task { @MainActor in + PushNotificationManager.shared.handleDeviceToken(deviceToken) + } } + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + Task { @MainActor in + PushNotificationManager.shared.handleRegistrationError(error) + } + } + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + let payload = PushPayload(userInfo: userInfo) + Task { @MainActor in + PushNotificationManager.shared.handleRemoteNotification(payload) + completionHandler(.newData) + } + } } - diff --git a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..24cada9 100644 --- a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..508217dfff459dab56c4be632c155bfea58865a9 GIT binary patch literal 108416 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

>9_+FTqmo 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 literal 0 HcmV?d00001 diff --git a/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json new file mode 100644 index 0000000..565e49d --- /dev/null +++ b/suixinkan_ios/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_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png b/suixinkan_ios/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_ios/Core/Design/AppContentUnavailableView.swift b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift index 0c76646..e2082d3 100644 --- a/suixinkan_ios/Core/Design/AppContentUnavailableView.swift +++ b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift @@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView { actionsContainer.isHidden = actions.isEmpty } + /// 配置Views展示内容。 private func configureViews( title: String, systemImage: String, diff --git a/suixinkan_ios/Core/Design/AppMetrics.swift b/suixinkan_ios/Core/Design/AppMetrics.swift index 213b27f..7250bba 100644 --- a/suixinkan_ios/Core/Design/AppMetrics.swift +++ b/suixinkan_ios/Core/Design/AppMetrics.swift @@ -9,6 +9,7 @@ import CoreGraphics /// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。 final class AppMetrics { + /// 初始化实例。 private init() {} /// 字体尺寸实体,统一维护 App 内常用字号。 diff --git a/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift index a72891f..ccaa5fd 100644 --- a/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift +++ b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift @@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { nil } + /// 配置Views展示内容。 private func configureViews() { isUserInteractionEnabled = true accessibilityLabel = "加载中" @@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { ]) } + /// 刷新Presentation展示。 private func refreshPresentation(animated: Bool) { let shouldShow = state.isVisible let updates = { @@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { /// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。 #if canImport(Lottie) + /// 加载Animation数据。 private static func loadAnimation() -> LottieAnimation? { if let animation = LottieAnimation.named("loading") { return animation @@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { return nil } #else + /// 加载Animation数据。 private static func loadAnimation() -> Any? { nil } diff --git a/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift index 963cbd3..1ff1d80 100644 --- a/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift +++ b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift @@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject { private let geocoder = CLGeocoder() private var continuation: CheckedContinuation? + /// 初始化实例。 override init() { super.init() manager.delegate = self diff --git a/suixinkan_ios/Core/Map/Map.md b/suixinkan_ios/Core/Map/Map.md new file mode 100644 index 0000000..590fe98 --- /dev/null +++ b/suixinkan_ios/Core/Map/Map.md @@ -0,0 +1,16 @@ +# Core/Map 模块 + +## 职责 + +封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。 + +## 组件 + +- `OperatingAreaMapView`:绘制运营围栏 polygon;模拟器降级为坐标摘要。 +- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。 + +## 构建说明 + +- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。 +- 模拟器:不链接 AMap,自动展示文字/坐标兜底 UI。 +- 在 `Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key(需与 Bundle ID 绑定)。 diff --git a/suixinkan_ios/Core/Map/OperatingAreaMapView.swift b/suixinkan_ios/Core/Map/OperatingAreaMapView.swift new file mode 100644 index 0000000..fa583cf --- /dev/null +++ b/suixinkan_ios/Core/Map/OperatingAreaMapView.swift @@ -0,0 +1,199 @@ +// +// OperatingAreaMapView.swift +// suixinkan_ios +// + +import CoreLocation +import SnapKit +import UIKit + +/// 运营区域围栏地图:真机启用高德 polygon;模拟器展示坐标摘要。 +final class OperatingAreaMapView: UIView { + var rings: [OperatingFenceRing] = [] { + didSet { updateContent() } + } + + private let contentContainer = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentContainer.clipsToBounds = true + contentContainer.layer.cornerRadius = AppMetrics.CornerRadius.card + addSubview(contentContainer) + contentContainer.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + updateContent() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateContent() { + contentContainer.subviews.forEach { $0.removeFromSuperview() } + #if AMAP_ENABLED + let mapView = OperatingAreaAMapView(rings: rings) + contentContainer.addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + #else + let fallback = OperatingAreaMapFallbackView(rings: rings) + contentContainer.addSubview(fallback) + fallback.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + #endif + } +} + +#if AMAP_ENABLED +import MAMapKit + +private final class OperatingAreaAMapView: UIView, MAMapViewDelegate { + private let mapView = MAMapView() + private var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:] + private var rings: [OperatingFenceRing] + + init(rings: [OperatingFenceRing]) { + self.rings = rings + super.init(frame: .zero) + mapView.delegate = self + mapView.showsCompass = false + mapView.showsScale = false + mapView.isRotateEnabled = false + addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + applyRings() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyRings() { + ringsByOverlay.removeAll() + mapView.removeOverlays(mapView.overlays) + mapView.removeAnnotations(mapView.annotations) + + var allCoordinates: [CLLocationCoordinate2D] = [] + for ring in rings { + var coordinates = ring.points.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + guard coordinates.count >= 3 else { continue } + guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else { + continue + } + ringsByOverlay[ObjectIdentifier(polygon)] = ring + mapView.add(polygon) + allCoordinates.append(contentsOf: coordinates) + + if let center = ring.centerCoordinate { + let annotation = MAPointAnnotation() + annotation.coordinate = center + annotation.title = ring.regionName + mapView.addAnnotation(annotation) + } + } + + if let region = Self.region(for: allCoordinates) { + mapView.setRegion(region, animated: false) + } + } + + func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { + guard let polygon = overlay as? MAPolygon else { return nil } + let ring = ringsByOverlay[ObjectIdentifier(polygon)] + let renderer = MAPolygonRenderer(polygon: polygon) + let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue + renderer?.strokeColor = color + renderer?.fillColor = color.withAlphaComponent(0.18) + renderer?.lineWidth = 3 + return renderer + } + + private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? { + guard !coordinates.isEmpty else { return nil } + let minLat = coordinates.map(\.latitude).min() ?? 0 + let maxLat = coordinates.map(\.latitude).max() ?? 0 + let minLng = coordinates.map(\.longitude).min() ?? 0 + let maxLng = coordinates.map(\.longitude).max() ?? 0 + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLng + maxLng) / 2 + ) + let span = MACoordinateSpan( + latitudeDelta: max((maxLat - minLat) * 1.4, 0.01), + longitudeDelta: max((maxLng - minLng) * 1.4, 0.01) + ) + return MACoordinateRegion(center: center, span: span) + } +} + +private extension OperatingFenceRing { + var centerCoordinate: CLLocationCoordinate2D? { + guard !points.isEmpty else { return nil } + let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count) + let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count) + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} +#endif + +private final class OperatingAreaMapFallbackView: UIView { + init(rings: [OperatingFenceRing]) { + super.init(frame: .zero) + backgroundColor = UIColor(hex: 0xEEF2F7) + + let titleLabel = UILabel() + titleLabel.text = "模拟器未启用高德地图,以下为围栏坐标摘要" + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .medium) + titleLabel.textColor = AppDesign.textSecondary + titleLabel.numberOfLines = 0 + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + + addSubview(titleLabel) + addSubview(stack) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + stack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small) + make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + + for ring in rings.prefix(6) { + let label = UILabel() + label.numberOfLines = 0 + label.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + label.textColor = AppDesign.textSecondary + let name = ring.regionName.isEmpty ? "未命名区域" : ring.regionName + let coords = ring.points.prefix(4) + .map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) } + .joined(separator: " ") + label.text = "\(name)\n\(coords)" + stack.addArrangedSubview(label) + } + + if rings.isEmpty { + let empty = UILabel() + empty.text = "暂无围栏数据" + empty.textColor = AppDesign.placeholder + stack.addArrangedSubview(empty) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift b/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift new file mode 100644 index 0000000..17a86ba --- /dev/null +++ b/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift @@ -0,0 +1,139 @@ +// +// PunchPointMapPickerView.swift +// suixinkan_ios +// + +import CoreLocation +import SnapKit +import UIKit + +/// 打卡点地图选点:真机高德地图点选 + 逆地理;模拟器展示提示。 +@MainActor +final class PunchPointMapPickerView: UIView { + var onLocationPicked: ((Double, Double, String) -> Void)? + + var coordinate: CLLocationCoordinate2D? { + didSet { updatePin() } + } + + private let hintLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + layer.cornerRadius = AppMetrics.CornerRadius.card + backgroundColor = UIColor(hex: 0xEEF2F7) + + hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + hintLabel.textColor = AppDesign.textSecondary + hintLabel.numberOfLines = 0 + hintLabel.textAlignment = .center + + #if AMAP_ENABLED + hintLabel.text = "点击地图选择打卡位置" + let mapView = PunchPointAMapPickerView { [weak self] lat, lng, address in + self?.onLocationPicked?(lat, lng, address) + } + addSubview(mapView) + addSubview(hintLabel) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + hintLabel.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview().inset(8) + } + #else + hintLabel.text = "模拟器未启用高德地图,请使用「当前位置」按钮选点" + addSubview(hintLabel) + hintLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + #endif + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updatePin() { + // Pin updates handled inside AMap subview when coordinate is set externally. + } + + func setCoordinate(latitude: Double, longitude: Double) { + coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} + +#if AMAP_ENABLED +import AMapSearchKit +import MAMapKit + +private final class PunchPointAMapPickerView: UIView, MAMapViewDelegate, AMapSearchDelegate { + private let mapView = MAMapView() + private let searchAPI = AMapSearchAPI() + private var pin: MAPointAnnotation? + private var pendingRegeo: CheckedContinuation? + private let onPick: (Double, Double, String) -> Void + + init(onPick: @escaping (Double, Double, String) -> Void) { + self.onPick = onPick + super.init(frame: .zero) + searchAPI?.delegate = self + mapView.delegate = self + mapView.showsUserLocation = true + mapView.userTrackingMode = .none + mapView.zoomLevel = 16 + addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { + Task { await handlePick(coordinate) } + } + + private func handlePick(_ coordinate: CLLocationCoordinate2D) async { + if let pin { + mapView.removeAnnotation(pin) + } + let annotation = MAPointAnnotation() + annotation.coordinate = coordinate + pin = annotation + mapView.addAnnotation(annotation) + + let address = await reverseGeocode(coordinate) + onPick(coordinate.latitude, coordinate.longitude, address) + } + + private func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async -> String { + await withCheckedContinuation { continuation in + pendingRegeo = continuation + let request = AMapReGeocodeSearchRequest() + request.location = AMapGeoPoint.location( + withLatitude: CGFloat(coordinate.latitude), + longitude: CGFloat(coordinate.longitude) + ) + request.requireExtension = true + searchAPI?.aMapReGoecodeSearch(request) + } + } + + func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { + let address = response.regeocode.formattedAddress ?? "已选位置" + pendingRegeo?.resume(returning: address) + pendingRegeo = nil + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + pendingRegeo?.resume(returning: "已选位置") + pendingRegeo = nil + } +} +#endif diff --git a/suixinkan_ios/Core/Push/PushNotificationManager.swift b/suixinkan_ios/Core/Push/PushNotificationManager.swift index 480937b..4c4760c 100644 --- a/suixinkan_ios/Core/Push/PushNotificationManager.swift +++ b/suixinkan_ios/Core/Push/PushNotificationManager.swift @@ -21,6 +21,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate private let tokenDefaultsKey = "apns_device_token" private let uploadedTokenDefaultsKey = "apns_uploaded_token" + /// 初始化实例。 override init() { super.init() } @@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate route(from: payload) } + /// user通知Center相关逻辑。 nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate completionHandler([.banner, .list, .sound, .badge]) } + /// user通知Center相关逻辑。 nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate } } + /// 根据执行路由跳转。 private func route(from payload: PushPayload) { guard let router else { return } @@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate } } + /// 导航至HomeRoute页面。 private func navigateHomeRoute(_ route: HomeRoute) { guard let router else { return } router.select(.home) diff --git a/suixinkan_ios/Core/Push/PushPayload.swift b/suixinkan_ios/Core/Push/PushPayload.swift index 64554e8..7d6f3db 100644 --- a/suixinkan_ios/Core/Push/PushPayload.swift +++ b/suixinkan_ios/Core/Push/PushPayload.swift @@ -9,6 +9,7 @@ import Foundation /// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。 enum APNsDeviceToken { + /// 将二进制数据转为十六进制字符串。 static func hexString(from data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } @@ -16,6 +17,7 @@ enum APNsDeviceToken { /// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。 struct PushPayload: Sendable { + /// 枚举,定义相关常量或状态。 enum Route: Sendable, Equatable { case payment case order @@ -64,6 +66,7 @@ struct PushPayload: Sendable { return .messageCenter } + /// mergeJSON相关逻辑。 nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { if let dict = value as? [String: Any] { for (key, value) in dict { @@ -84,6 +87,7 @@ struct PushPayload: Sendable { } } + /// 安全地将任意值转为字符串。 nonisolated private static func stringValue(_ value: Any?) -> String { switch value { case let string as String: diff --git a/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift index 769723a..40d2294 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift @@ -9,6 +9,7 @@ import Foundation /// 排队语音播报内容。 struct ScenicQueueAnnouncement: Equatable { + /// 枚举,定义相关常量或状态。 enum Kind: Equatable { case newTickets(count: Int) case calledTicket(id: Int64) diff --git a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift index fb9dd0a..29ce773 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift @@ -69,6 +69,7 @@ final class ScenicQueueRuntime { self.scenePhase = scenePhase guard !suspendedByQueueScreen else { + // 排队页自行监听时,避免双通道轮询 stop() return } @@ -86,6 +87,7 @@ final class ScenicQueueRuntime { return } + // 景区或打卡点切换时重置播报状态,避免跨点误报 if lastScenicId != scenicId || lastSpotId != spotId { resetSnapshot() lastScenicId = scenicId @@ -93,6 +95,7 @@ final class ScenicQueueRuntime { } if scenePhase == .background, !backgroundPollingEnabled { + // 用户关闭后台轮询时,进入后台即停止 stop() return } @@ -126,6 +129,7 @@ final class ScenicQueueRuntime { Task { await pollOnce() } } + /// 启动IfNeeded流程。 private func startIfNeeded() { guard pollTask == nil else { return } isMonitoring = true @@ -137,6 +141,7 @@ final class ScenicQueueRuntime { } } + /// 执行Loop循环或任务。 private func runLoop() async { while !Task.isCancelled { await pollOnce() @@ -145,6 +150,7 @@ final class ScenicQueueRuntime { } } + /// 轮询Once数据。 private func pollOnce() async { guard let api, let scenicId else { return } let spotId = selectedSpotId() @@ -168,6 +174,7 @@ final class ScenicQueueRuntime { page: 1, pageSize: 20 ) + // 并行拉取统计与排队列表,减少单次轮询耗时 let (stats, home) = try await (statsData, homeData) handle(stats: stats, tickets: home.list?.list ?? []) lastError = nil @@ -176,6 +183,7 @@ final class ScenicQueueRuntime { } } + /// 处理相关事件。 private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -190,6 +198,7 @@ final class ScenicQueueRuntime { } } + /// 轮询IntervalSeconds数据。 private func pollIntervalSeconds() -> UInt64 { switch scenePhase { case .active: @@ -203,11 +212,13 @@ final class ScenicQueueRuntime { } } + /// 读取用户当前选中的打卡点 ID。 private func selectedSpotId() -> Int { ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) } + /// 重置Snapshot状态。 private func resetSnapshot() { announcementState.reset() lastQueueCount = 0 @@ -215,6 +226,7 @@ final class ScenicQueueRuntime { lastSpokenText = "" } + /// 申请后台任务,保证进入后台时仍可短时轮询。 private func beginBackgroundTask() { guard backgroundTaskId == .invalid else { return } backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in @@ -225,6 +237,7 @@ final class ScenicQueueRuntime { } } + /// 结束后台任务。 private func endBackgroundTask() { guard backgroundTaskId != .invalid else { return } UIApplication.shared.endBackgroundTask(backgroundTaskId) @@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() private var pendingContinuations: [CheckedContinuation] = [] + /// 初始化实例。 override init() { super.init() synthesizer.delegate = self @@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { resumePending() } + /// 入队等待处理。 private func enqueue(_ normalized: String, continuation: CheckedContinuation?, replacePending: Bool) { if replacePending { synthesizer.stopSpeaking(at: .immediate) @@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { synthesizer.speak(utterance) } + /// speechSynthesizer相关逻辑。 nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } + /// speechSynthesizer相关逻辑。 nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } + /// 恢复Pending流程。 private func resumePending() { let continuations = pendingContinuations pendingContinuations.removeAll() diff --git a/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift index 7d0d814..03647dd 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift @@ -74,6 +74,7 @@ final class ScenicQueueSocketClient { webSocketTask = nil } + /// 持续接收 WebSocket 消息并分发处理。 private func receiveLoop( task: URLSessionWebSocketTask, onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void @@ -130,6 +131,7 @@ final class ScenicQueueSocketClient { return ScenicQueueSocketMessage(code: code, data: socketData) } + /// 安全地将任意值转为字符串。 private static func stringValue(_ value: Any?) -> String { switch value { case let value as String: @@ -143,6 +145,7 @@ final class ScenicQueueSocketClient { } } + /// int值相关逻辑。 private static func intValue(_ value: Any?) -> Int? { if let value = value as? Int { return value } if let value = value as? NSNumber { return value.intValue } @@ -150,6 +153,7 @@ final class ScenicQueueSocketClient { return nil } + /// int64值相关逻辑。 private static func int64Value(_ value: Any?) -> Int64? { if let value = value as? Int64 { return value } if let value = value as? Int { return Int64(value) } diff --git a/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift index f1d3e54..8c7cec1 100644 --- a/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift +++ b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift @@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController { private let pageTitle: String private let uri: String + /// 初始化实例。 init(title: String, uri: String) { self.pageTitle = title self.uri = uri @@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) diff --git a/suixinkan_ios/Core/UI/RemoteImage.swift b/suixinkan_ios/Core/UI/RemoteImage.swift index 5bcb70e..ccffd1e 100644 --- a/suixinkan_ios/Core/UI/RemoteImage.swift +++ b/suixinkan_ios/Core/UI/RemoteImage.swift @@ -8,6 +8,7 @@ import Kingfisher import UIKit +/// `UIImageView` 远程图片加载扩展,统一走 Kingfisher 缓存与占位策略。 extension UIImageView { /// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。 func loadRemoteImage( @@ -68,11 +69,13 @@ extension UIImageView { kf.cancelDownloadTask() } + /// 规范化dURLString格式。 private func normalizedURLString(from urlString: String?) -> String? { let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } + /// avatarPlaceholder相关逻辑。 private static func avatarPlaceholder( systemImageName: String, iconSize: CGFloat, @@ -98,6 +101,7 @@ extension UIImageView { } } +/// `UIImageView` 头像圆角辅助扩展。 extension UIImageView { /// 在布局变化后刷新圆形头像圆角。 func refreshRemoteAvatarCornerRadius() { diff --git a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift index 46011d5..cec5414 100644 --- a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift +++ b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift @@ -23,6 +23,7 @@ private final class ViewModelBindingToken { private weak var owner: AnyObject? private let handler: () -> Void + /// 初始化实例。 init(owner: AnyObject, handler: @escaping () -> Void) { self.owner = owner self.handler = handler @@ -33,17 +34,22 @@ private final class ViewModelBindingToken { } } +/// `UIViewController` 通用 UI 与全局反馈扩展。 extension UIViewController { + /// 全局依赖容器快捷访问。 var appServices: AppServices { AppServices.shared } + /// 展示全局 Toast 提示。 func showToast(_ message: String) { appServices.toastCenter.show(message) } + /// 展示全局 Loading 遮罩。 func showGlobalLoading(_ message: String = "") { appServices.globalLoading.show(message: message) } + /// 隐藏GlobalLoading。 func hideGlobalLoading() { appServices.globalLoading.hide() } diff --git a/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift b/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift new file mode 100644 index 0000000..28653d6 --- /dev/null +++ b/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift @@ -0,0 +1,173 @@ +// +// ListDiffableSupport.swift +// suixinkan +// +// 列表 Diffable Data Source 通用类型与布局辅助。 +// + +import SnapKit +import UIKit + +// MARK: - Module Table(简单单列 / 多 section 列表) + +/// 模块列表 section 标识,使用 section 索引。 +typealias ModuleTableSection = Int + +/// 模块列表行标识,格式为 `section-row`。 +typealias ModuleTableRow = String + +/// 构建模块列表行标识。 +func moduleTableRowID(section: Int, row: Int) -> ModuleTableRow { + "\(section)-\(row)" +} + +/// 从行标识解析 indexPath。 +func moduleTableIndexPath(from rowID: ModuleTableRow) -> IndexPath? { + let parts = rowID.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 2, let section = Int(parts[0]), let row = Int(parts[1]) else { return nil } + return IndexPath(row: row, section: section) +} + +// MARK: - Simple Table(独立页面的固定行列表) + +/// 简单列表 section 标识。 +typealias SimpleTableSection = Int + +/// 简单列表行标识。 +typealias SimpleTableRow = String + +// MARK: - Collection Layout + +/// UICollectionView 布局工厂,提供全宽卡片与网格 section 的 Compositional Layout。 +enum CollectionDiffableLayout { + /// 全宽单行 section,高度由 estimated 或 absolute 决定。 + static func fullWidthSection(estimatedHeight: CGFloat = 100) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ) + ) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ), + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 固定高度全宽 section。 + static func fullWidthSection(height: CGFloat) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ) + ) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 三列网格 section,用于首页 / 更多功能菜单。 + static func gridSection( + columns: Int = 3, + itemHeight: CGFloat = 102, + interItemSpacing: CGFloat = 15, + lineSpacing: CGFloat = 15, + contentInsets: NSDirectionalEdgeInsets? = nil + ) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0 / CGFloat(columns)), + heightDimension: .absolute(itemHeight) + ) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemHeight) + ), + subitem: item, + count: columns + ) + group.interItemSpacing = .fixed(interItemSpacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = lineSpacing + section.contentInsets = contentInsets ?? NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 带标题的 section 头。 + static func addHeader( + to section: NSCollectionLayoutSection, + height: CGFloat = 36 + ) -> NSCollectionLayoutSection { + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + return section + } +} + +/// 通用 section 标题补充视图。 +final class CollectionSectionHeaderView: UICollectionReusableView { + static let reuseID = "CollectionSectionHeaderView" + + private let titleLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + titleLabel.textColor = AppDesignUIKit.textSecondary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.bottom.equalToSuperview().inset(4) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置 section 标题文本。 + func configure(title: String) { + titleLabel.text = title + } +} diff --git a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift index a75f0a1..840126e 100644 --- a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift +++ b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift @@ -12,6 +12,7 @@ import UIKit final class TitleSubtitleTableViewCell: UITableViewCell { static let reuseIdentifier = "TitleSubtitleTableViewCell" + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) selectionStyle = .default @@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + /// 填充标题、副标题与详情文本,副标题优先于详情展示。 func configure(title: String, subtitle: String? = nil, detail: String? = nil) { textLabel?.text = title if let subtitle, !subtitle.isEmpty { @@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell { } } -/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。 -@MainActor -class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { +/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。 +class ModuleTableViewController: UIViewController, UITableViewDelegate { let services = AppServices.shared let tableView = UITableView(frame: .zero, style: .insetGrouped) private let refreshControl = UIRefreshControl() private let activityIndicator = UIActivityIndicatorView(style: .medium) private var viewModelReloadHandler: (() -> Void)? + /// Diffable 数据源,驱动列表展示与动画刷新。 + private(set) var tableDataSource: UITableViewDiffableDataSource! + var isLoading = false { didSet { - if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 { + if isLoading, tableDataSource.snapshot().numberOfItems == 0 { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() @@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl } } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) navigationItem.largeTitleDisplayMode = .never - tableView.dataSource = self + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ModuleTableRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + return self.tableCell(for: indexPath, row: row, in: tableView) + } + tableView.delegate = self tableView.backgroundColor = .clear tableView.register( @@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl Task { await reloadContent() } } + /// 绑定 ViewModel 数据变更回调,子类在 `reloadContent` 完成后调用。 func bindViewModel(onChange: (() -> Void)?) { viewModelReloadHandler = onChange } - func reloadTable() { - tableView.reloadData() + /// 通过 Diffable snapshot 刷新列表,替代 reloadData。 + func reloadTable(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) } + /// 返回 section 数量,子类可覆写以支持多 section。 + func numberOfTableSections() -> Int { 1 } + + /// 返回指定 section 行数,子类必须覆写。 + func tableRowCount(in section: Int) -> Int { + section == 0 ? tableRowCount() : 0 + } + + /// 返回列表行数(单 section 场景),子类覆写。 func tableRowCount() -> Int { 0 } + /// 构建 Diffable snapshot,子类一般无需覆写。 + func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + for section in 0.. Int { 1 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - tableRowCount() - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供 Cell,子类可覆写以自定义多 section 展示。 + func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl return cell } + /// 处理行选中事件,子类覆写以响应跳转或操作。 + func didSelectTableRow(at indexPath: IndexPath) {} + + /// 加载或刷新页面数据,子类覆写以实现网络请求与状态更新。 + func reloadContent() async {} + + /// section 标题,子类覆写以展示分组标题。 + func tableSectionTitle(for section: Int) -> String? { nil } + + /// 下拉刷新触发,重新加载内容并结束刷新动画。 + @objc private func handleRefresh() { + Task { + await reloadContent() + refreshControl.endRefreshing() + } + } + + /// UITableView 代理:展示 section 标题。 + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + tableSectionTitle(for: section) + } + + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) didSelectTableRow(at: indexPath) } + /// UITableView 代理:行即将展示,用于分页加载等。 func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { willDisplayTableRow(at: indexPath) } + /// willDisplayTableRow 回调处理。 func willDisplayTableRow(at indexPath: IndexPath) {} } +/// `ModuleTableViewController` 扩展,提供 ViewModel 绑定便捷方法。 extension ModuleTableViewController { + /// 将 ViewModel 的 onChange 与列表刷新联动,并在绑定时立即刷新一次。 func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) { if let bindable = viewModel as? ViewModelBindable { bindable.onChange = { [weak self] in @@ -146,6 +193,7 @@ extension ModuleTableViewController { } } reload() + reloadTable(animated: false) } } @@ -154,3 +202,140 @@ extension ModuleTableViewController { protocol ViewModelBindable: AnyObject { var onChange: (() -> Void)? { get set } } + +/// 简单固定行列表基类,使用 UITableView Diffable,适用于设置、详情等页面。 +class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate { + let tableView: UITableView + + private(set) var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 + init(style: UITableView.Style = .insetGrouped) { + tableView = UITableView(frame: .zero, style: style) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 构建 snapshot,子类必须覆写。 + func buildSnapshot() -> NSDiffableDataSourceSnapshot { + NSDiffableDataSourceSnapshot() + } + + /// 配置 Cell,子类必须覆写。 + func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {} + + /// 行选中,子类可覆写。 + func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {} + + /// section 标题。 + func sectionTitle(for section: SimpleTableSection) -> String? { nil } + + /// section 页脚。 + func sectionFooter(for section: SimpleTableSection) -> String? { nil } + + /// 应用 snapshot 刷新列表。 + func applySnapshot(animated: Bool = true) { + tableDataSource.apply(buildSnapshot(), animatingDifferences: animated) + } + + /// 视图加载完成后的 UI 初始化。 + override func viewDidLoad() { + super.viewDidLoad() + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: SimpleTableRow) -> UITableViewCell? in + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + self?.configureCell(cell, row: row, at: indexPath) + return cell + } + tableView.delegate = self + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + applySnapshot(animated: false) + } + + /// UITableView 代理:section 标题。 + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionTitle(for: sectionID) + } + + /// UITableView 代理:section 页脚。 + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionFooter(for: sectionID) + } + + /// UITableView 代理:行选中。 + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + didSelectRow(row, at: indexPath) + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +/// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。 +class ModuleCollectionViewController: UIViewController { + let services = AppServices.shared + let collectionView: UICollectionView + private let refreshControl = UIRefreshControl() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + + /// 初始化实例。 + init(collectionViewLayout: UICollectionViewLayout) { + collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 视图加载完成后的 UI 初始化。 + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0xF5F7FA) + navigationItem.largeTitleDisplayMode = .never + + collectionView.backgroundColor = .clear + collectionView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) + + activityIndicator.hidesWhenStopped = true + view.addSubview(collectionView) + view.addSubview(activityIndicator) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + activityIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + Task { await reloadContent() } + } + + /// 加载或刷新页面数据,子类覆写。 + func reloadContent() async {} + + /// 下拉刷新触发。 + @objc private func handleRefresh() { + Task { + await reloadContent() + refreshControl.endRefreshing() + } + } +} diff --git a/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift index 94e3a22..4342c90 100644 --- a/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift +++ b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class CloudStorageViewController: ModuleTableViewController { private let viewModel = CloudStorageViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "云盘" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.files.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let file = viewModel.files[indexPath.row] cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI) } + /// willDisplayTableRow 回调处理。 override func willDisplayTableRow(at indexPath: IndexPath) { guard indexPath.row >= viewModel.files.count - 2 else { return } Task { await viewModel.loadMore(api: services.assetsAPI) } } + /// openTransit相关逻辑。 @objc private func openTransit() { navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true) } @@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {} final class CloudStorageTransitViewController: ModuleTableViewController { private let store = CloudTransferStore.shared + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "传输记录" super.viewDidLoad() store.onChange = { [weak self] in self?.reloadTable() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { store.records.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = store.records[indexPath.row] cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%") } + /// 刷新Content。 override func reloadContent() async {} } +/// MediaLibraryKindRoute,业务类型定义。 enum MediaLibraryKindRoute { case material case sample @@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController { private let kind: MediaLibraryKindRoute private let viewModel: MediaLibraryViewModel + /// 初始化实例。 init(kind: MediaLibraryKindRoute = .material) { self.kind = kind switch kind { @@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = kind == .sample ? "样片库" : "素材库" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI) } + /// open上传相关逻辑。 @objc private func openUpload() { navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true) } @@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { private let viewModel = MediaLibraryEditorViewModel() private let nameField = UITextField() + /// 初始化实例。 init(kind: MediaLibraryKindRoute) { self.kind = kind super.init(nibName: nil, bundle: nil) @@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = kind == .sample ? "上传样片" : "上传素材" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.projects.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let project = viewModel.projects[indexPath.row] cell.configure(title: project.name, subtitle: project.statusName) } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI) } + /// 提交。 @objc private func submit() { viewModel.name = nameField.text ?? "" Task { @@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {} final class AlbumListViewController: ModuleTableViewController { private let viewModel = AlbumListViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "相册" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId) } @@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {} final class AlbumTrailerViewController: ModuleTableViewController { private let viewModel = AlbumTrailerViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "相册预告" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId) } diff --git a/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift index 9148992..41c38a4 100644 --- a/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift +++ b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift @@ -6,9 +6,14 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias AccountSelectionSection = Int +private typealias AccountSelectionItem = String + @MainActor /// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。 -final class AccountSelectionViewController: UIViewController { +final class AccountSelectionViewController: UIViewController, UITableViewDelegate { private let payload: AccountSelectionPayload private var isLoading: Bool @@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController { private var selectedAccountId: String? private let tableView = UITableView(frame: .zero, style: .plain) + + /// Diffable 数据源,驱动账号列表与选中态刷新。 + private var tableDataSource: UITableViewDiffableDataSource! private let confirmButton = UIButton(type: .system) private let bottomBar = UIView() + /// 初始化实例。 init( payload: AccountSelectionPayload, isLoading: Bool, @@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "选择账号" @@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController { selectedAccountId = payload.accounts.first?.id configureTableView() + configureTableDataSource() configureBottomBar() } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSelectionItem) -> UITableViewCell? in + guard let self, + let cell = tableView.dequeueReusableCell( + withIdentifier: AccountSelectionCell.reuseIdentifier, + for: indexPath + ) as? AccountSelectionCell, + let account = self.payload.accounts.first(where: { $0.id == item }) else { + return UITableViewCell() + } + cell.configure(account: account, selected: account.id == self.selectedAccountId) + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(payload.accounts.map(\.id), toSection: 0) + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 更新Loading状态。 func updateLoading(_ loading: Bool) { isLoading = loading isModalInPresentation = loading @@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController { selectedAccount != nil && !isLoading } + /// 配置TableView展示内容。 private func configureTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none - tableView.dataSource = self tableView.delegate = self tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier) view.addSubview(tableView) @@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController { } } + /// 配置BottomBar展示内容。 private func configureBottomBar() { bottomBar.backgroundColor = .white @@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController { } } + /// 点击cancel的处理逻辑。 @objc private func cancelTapped() { onCancel() dismiss(animated: true) } + /// 点击confirm的处理逻辑。 @objc private func confirmTapped() { guard let selectedAccount else { return } onConfirm(selectedAccount) } } -extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - payload.accounts.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell( - withIdentifier: AccountSelectionCell.reuseIdentifier, - for: indexPath - ) as? AccountSelectionCell else { - return UITableViewCell() - } - let account = payload.accounts[indexPath.row] - cell.configure(account: account, selected: account.id == selectedAccountId) - return cell - } - +extension AccountSelectionViewController { + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - selectedAccountId = payload.accounts[indexPath.row].id - tableView.reloadData() + tableView.deselectRow(at: indexPath, animated: true) + guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return } + selectedAccountId = item + applyTableSnapshot(animated: false, reconfigure: true) confirmButton.isEnabled = canConfirm confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6) } @@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell { private let currentTag = UILabel() private let checkmark = UIImageView() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell { nil } + /// 配置展示内容。 func configure(account: AccountSwitchAccount, selected: Bool) { titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title subtitleLabel.text = account.subtitle diff --git a/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift index 954170d..ce4a604 100644 --- a/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift +++ b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift @@ -26,6 +26,7 @@ final class LoginViewController: UIViewController { private let privacyPolicyButton = UIButton(type: .system) private let loginButton = UIButton(type: .system) + /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) @@ -36,6 +37,7 @@ final class LoginViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0x0B1220) @@ -44,6 +46,7 @@ final class LoginViewController: UIViewController { viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences()) } + /// 配置Views展示内容。 private func configureViews() { backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.clipsToBounds = true @@ -161,6 +164,7 @@ final class LoginViewController: UIViewController { view.addGestureRecognizer(tap) } + /// 配置AgreementText展示内容。 private func configureAgreementText() { privacyLabel.numberOfLines = 0 privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) @@ -197,6 +201,7 @@ final class LoginViewController: UIViewController { } } + /// 绑定ViewModel回调或数据。 private func bindViewModel() { viewModel.onChange = { [weak self] in self?.renderViewModel() @@ -204,6 +209,7 @@ final class LoginViewController: UIViewController { renderViewModel() } + /// render视图模型相关逻辑。 private func renderViewModel() { if usernameField.textField.text != viewModel.username { usernameField.textField.text = viewModel.username @@ -241,38 +247,46 @@ final class LoginViewController: UIViewController { private weak var accountSelectionController: AccountSelectionViewController? + /// username变更相关逻辑。 @objc private func usernameChanged() { viewModel.username = usernameField.textField.text ?? "" viewModel.normalizeUsernameCountryCodeIfNeeded() services.toastCenter.dismiss() } + /// password变更相关逻辑。 @objc private func passwordChanged() { viewModel.password = passwordField.textField.text ?? "" services.toastCenter.dismiss() } + /// togglePrivacy相关逻辑。 @objc private func togglePrivacy() { viewModel.privacyChecked.toggle() } + /// openUserAgreement相关逻辑。 @objc private func openUserAgreement() { showToast("用户协议页面待接入") } + /// openPrivacyPolicy相关逻辑。 @objc private func openPrivacyPolicy() { showToast("隐私政策页面待接入") } + /// 点击login的处理逻辑。 @objc private func loginTapped() { view.endEditing(true) performLogin() } + /// dismiss键盘相关逻辑。 @objc private func dismissKeyboard() { view.endEditing(true) } + /// perform登录相关逻辑。 private func performLogin() { if let validationError = viewModel.validateForLogin() { if validationError == .privacyUnchecked { @@ -307,6 +321,7 @@ final class LoginViewController: UIViewController { } } + /// 弹出AgreementSheet页面。 private func presentAgreementSheet() { let controller = LoginAgreementConsentViewController( onOpenAgreement: { [weak self] title in @@ -324,6 +339,7 @@ final class LoginViewController: UIViewController { present(controller, animated: true) } + /// 弹出AccountSelection页面。 private func presentAccountSelection(_ payload: AccountSelectionPayload) { let controller = AccountSelectionViewController( payload: payload, @@ -342,6 +358,7 @@ final class LoginViewController: UIViewController { present(navigation, animated: true) } + /// select账号相关逻辑。 private func selectAccount(_ account: AccountSwitchAccount) { Task { do { @@ -359,6 +376,7 @@ final class LoginViewController: UIViewController { } } + /// complete登录相关逻辑。 private func completeLogin(with response: V9AuthResponse) async { do { try await services.authSessionCoordinator.completeLogin( @@ -378,6 +396,7 @@ final class LoginViewController: UIViewController { } extension LoginViewController: UITextFieldDelegate { + /// text字段ShouldReturn相关逻辑。 func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField === usernameField.textField { passwordField.textField.becomeFirstResponder() @@ -396,6 +415,7 @@ private final class LoginInputField: UIView { private let toggleButton = UIButton(type: .custom) private var isSecure = false + /// 初始化实例。 init(iconName: String, placeholder: String, isSecure: Bool) { self.isSecure = isSecure super.init(frame: .zero) @@ -450,12 +470,14 @@ private final class LoginInputField: UIView { nil } + /// setSecureEntry相关逻辑。 func setSecureEntry(_ secure: Bool) { textField.isSecureTextEntry = secure let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible" toggleButton.setImage(UIImage(named: imageName), for: .normal) } + /// toggleVisibility相关逻辑。 @objc private func toggleVisibility() { onToggleVisibility?() } @@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController { private let onOpenAgreement: (String) -> Void private let onAgreeAndContinue: () -> Void + /// 初始化实例。 init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) { self.onOpenAgreement = onOpenAgreement self.onAgreeAndContinue = onAgreeAndContinue @@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController { nil } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground @@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController { } } + /// 点击continue的处理逻辑。 @objc private func continueTapped() { dismiss(animated: true) { [onAgreeAndContinue] in onAgreeAndContinue() diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift index 5d18340..c741259 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift @@ -50,6 +50,7 @@ enum HomeMenuRouting { viewController.navigationController?.pushViewController(target, animated: true) } + /// Push Placeholder页面。 private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) { viewController.navigationController?.pushViewController( FeaturePlaceholderViewController(title: title, uri: uri), diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift index 7d9a33f..a2541ce 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift @@ -6,6 +6,19 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 全部功能页 section 标识,区分常用应用与更多功能网格。 +private enum HomeMoreSection: Hashable { + case commonApps + case moreFunctions +} + +/// 全部功能页 item 标识,以菜单 URI 作为唯一键。 +private enum HomeMoreItem: Hashable { + case menu(uri: String) +} + /// 首页全部功能页,支持常用应用增删和更多功能网格展示。 final class HomeMoreFunctionsViewController: UIViewController { @@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController { private let commonMenuStore = HomeCommonMenuStore() private var commonURIs: [String] = [] - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.dataSource = self - table.delegate = self - table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() + /// Diffable 数据源,驱动常用应用与更多功能两个网格 section。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "全部功能" view.backgroundColor = AppDesignUIKit.pageBackground navigationItem.largeTitleDisplayMode = .never - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + configureDataSource() + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applySnapshot() } rebuildMenus() @@ -44,6 +66,95 @@ final class HomeMoreFunctionsViewController: UIViewController { } } + /// 构建 Compositional Layout,两个 section 均为带标题的三列网格。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex] + else { + return CollectionDiffableLayout.gridSection(itemHeight: 112) + } + let gridInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.mediumLarge, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.mediumLarge + ) + let grid = CollectionDiffableLayout.gridSection( + columns: 3, + itemHeight: 112, + interItemSpacing: 14, + lineSpacing: AppMetrics.Spacing.mediumLarge, + contentInsets: gridInsets + ) + switch section { + case .commonApps: + return CollectionDiffableLayout.addHeader(to: grid, height: 36) + case .moreFunctions: + return CollectionDiffableLayout.addHeader(to: grid, height: 36) + } + } + } + + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self, + case .menu(let uri) = item, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section] + else { return UICollectionViewCell() } + + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeMoreMenuItemCell.reuseID, + for: indexPath + ) as! HomeMoreMenuItemCell + + let isCommon = section == .commonApps + let items = isCommon ? self.commonItems : self.moreItems + guard let menuItem = items.first(where: { $0.uri == uri }) else { return cell } + + cell.configure(item: menuItem, isCommon: isCommon) { [weak self] in + self?.toggleCommon(menuItem, isCommon: isCommon) + } + return cell + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard let self, + kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + let title = section == .commonApps ? "常用应用" : "更多功能" + header.configure(title: title) + return header + } + } + + /// 根据当前常用应用与权限菜单构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.commonApps, .moreFunctions]) + snapshot.appendItems( + commonItems.map { HomeMoreItem.menu(uri: $0.uri) }, + toSection: .commonApps + ) + snapshot.appendItems( + moreItems.map { HomeMoreItem.menu(uri: $0.uri) }, + toSection: .moreFunctions + ) + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 按当前角色权限重建首页菜单与常用应用。 private func rebuildMenus() { let services = appServices viewModel.buildMenus( @@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController { currentRoleId: services.permissionContext.currentRole?.id ) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) - tableView.reloadData() + applySnapshot() } private var commonItems: [HomeMenuItem] { @@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController { viewModel.menuItems.filter { !isCommonURI($0.uri) } } + /// 按 URI 解析并返回可用菜单项。 private func menuItem(for uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) @@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController { ) } + /// 判断 URI 是否已在常用应用中。 private func isCommonURI(_ uri: String) -> Bool { let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey } } + /// 切换菜单项的常用应用状态。 private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) { if isCommon { commonURIs = commonMenuStore.remove(item.uri, current: commonURIs) } else { commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems) } - tableView.reloadData() + applySnapshot() } + /// 打开菜单对应页面。 private func openMenu(_ item: HomeMenuItem) { let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title) if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return } @@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController { } } -extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? "常用应用" : "更多功能" - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell - let isCommon = indexPath.section == 0 - let items = isCommon ? commonItems : moreItems - cell.configure(items: items, isCommon: isCommon) { [weak self] item in - self?.openMenu(item) - } onToggle: { [weak self] item in - self?.toggleCommon(item, isCommon: isCommon) - } - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let count = indexPath.section == 0 ? commonItems.count : moreItems.count - let rows = max(1, Int(ceil(Double(count) / 3.0))) - return CGFloat(rows) * 124 + 8 - } -} - -private final class HomeMoreMenuGridCell: UITableViewCell { - static let reuseID = "HomeMoreMenuGridCell" - - private var items: [HomeMenuItem] = [] - private var isCommon = false - private var onSelect: ((HomeMenuItem) -> Void)? - private var onToggle: ((HomeMenuItem) -> Void)? - - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.minimumInteritemSpacing = 14 - layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge - let view = UICollectionView(frame: .zero, collectionViewLayout: layout) - view.backgroundColor = .clear - view.isScrollEnabled = false - view.dataSource = self - view.delegate = self - view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID) - return view - }() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(collectionView) - collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure( - items: [HomeMenuItem], - isCommon: Bool, - onSelect: @escaping (HomeMenuItem) -> Void, - onToggle: @escaping (HomeMenuItem) -> Void - ) { - self.items = items - self.isCommon = isCommon - self.onSelect = onSelect - self.onToggle = onToggle - collectionView.reloadData() - } -} - -extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - items.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell - let item = items[indexPath.item] - cell.configure(item: item, isCommon: isCommon) { [weak self] in - self?.onToggle?(item) - } - return cell - } +// MARK: - UICollectionViewDelegate +extension HomeMoreFunctionsViewController: UICollectionViewDelegate { + /// 点击网格项打开对应功能页(加减按钮由 Cell 内部处理)。 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - onSelect?(items[indexPath.item]) - } + guard let item = dataSource.itemIdentifier(for: indexPath), + case .menu(let uri) = item + else { return } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width = (collectionView.bounds.width - 28) / 3 - return CGSize(width: width, height: 112) + let allItems = commonItems + moreItems + guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return } + openMenu(menuItem) } } +// MARK: - Menu Item Cell + +/// 全部功能页菜单网格单项 Cell,支持常用应用加减操作。 private final class HomeMoreMenuItemCell: UICollectionViewCell { static let reuseID = "HomeMoreMenuItemCell" @@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { private let toggleButton = UIButton(type: .system) private var onToggle: (() -> Void)? + /// 初始化实例。 override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white @@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + /// 配置菜单项展示内容与常用应用切换按钮。 func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) { self.onToggle = onToggle titleLabel.text = item.title @@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary } + /// 点击加减按钮切换常用应用状态。 @objc private func toggleTapped() { onToggle?() } } + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift index 08b5d65..f07efb1 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift @@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController { private let pageTitle: String private let uri: String? + /// 初始化实例。 init(title: String, uri: String? = nil) { pageTitle = title self.uri = uri @@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = pageTitle diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift index c85a52d..ac0f7d0 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift @@ -6,6 +6,26 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 首页 section 标识,按角色动态展示工作状态、门店与常用应用网格。 +private enum HomeSection: Hashable { + case workStatus + case locationReport + case storeInfo + case quickActions + case commonMenus +} + +/// 首页 item 标识;工作状态类 item 携带展示数据以支持倒计时 diff 刷新。 +private enum HomeItem: Hashable { + case workStatus(isOnline: Bool, secondsUntilReport: Int, reminderMinutes: Int) + case locationReport + case storeInfo(storeID: Int) + case quickActions(isOnline: Bool) + case menu(uri: String) +} + /// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。 final class HomeViewController: UIViewController { @@ -34,40 +54,55 @@ final class HomeViewController: UIViewController { return button }() - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.showsVerticalScrollIndicator = false - table.dataSource = self - table.delegate = self - table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID) - table.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.showsVerticalScrollIndicator = false + collection.delegate = self + collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID) + collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID) + collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID) + collection.register(HomeQuickActionsCell.self, forCellWithReuseIdentifier: HomeQuickActionsCell.reuseID) + collection.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() + /// Diffable 数据源,驱动多 section 卡片与常用应用网格。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = AppDesignUIKit.pageBackground setupTopBar() - setupTableView() + configureDataSource() + setupCollectionView() bindViewModel() rebuildMenus() observeContextChanges() } + /// 视图即将展示,刷新可见状态。 override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) startCountdownTimerIfNeeded() } + /// 视图即将消失,保存或清理临时状态。 override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) countdownTimer?.invalidate() countdownTimer = nil } + /// 初始化 TopBar 相关 UI 或状态。 private func setupTopBar() { let topBar = UIView() topBar.backgroundColor = .white @@ -85,20 +120,160 @@ final class HomeViewController: UIViewController { updateScenicTitle() } - private func setupTableView() { - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + /// 初始化 CollectionView 布局约束。 + private func setupCollectionView() { + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(78) make.leading.trailing.bottom.equalToSuperview() } } - private func bindViewModel() { - viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + /// 构建 Compositional Layout,按 section 类型分配全宽卡片或三列网格。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex] + else { + return CollectionDiffableLayout.fullWidthSection() + } + switch section { + case .workStatus: + return CollectionDiffableLayout.fullWidthSection(height: 100) + case .locationReport: + return CollectionDiffableLayout.fullWidthSection(height: 148) + case .storeInfo: + return CollectionDiffableLayout.fullWidthSection(height: 88) + case .quickActions: + return CollectionDiffableLayout.fullWidthSection(height: 118) + case .commonMenus: + return CollectionDiffableLayout.addHeader( + to: CollectionDiffableLayout.gridSection(itemHeight: 102), + height: 36 + ) + } } } + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + switch item { + case .workStatus(let online, let seconds, let reminder): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeWorkStatusCell.reuseID, + for: indexPath + ) as! HomeWorkStatusCell + cell.configure( + isOnline: online, + countdownText: self.countdownText(seconds: seconds), + reminderText: self.reminderText(minutes: reminder), + onOnlineTap: { [weak self] in self?.onlineTapped() }, + onReminderTap: { [weak self] in self?.reminderTapped() } + ) + return cell + case .locationReport: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeLocationReportCell.reuseID, + for: indexPath + ) as! HomeLocationReportCell + cell.configure(onReportTap: { [weak self] in self?.locationReportTapped() }) + return cell + case .storeInfo(let storeID): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeStoreInfoCell.reuseID, + for: indexPath + ) as! HomeStoreInfoCell + if let store = self.appServices.accountContext.currentStore, store.id == storeID { + cell.configure( + storeName: store.name, + scenicName: self.appServices.accountContext.currentScenic?.name + ) + } + return cell + case .quickActions(let online): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeQuickActionsCell.reuseID, + for: indexPath + ) as! HomeQuickActionsCell + cell.configure( + isOnline: online, + onPaymentTap: { [weak self] in self?.paymentTapped() }, + onTaskCreateTap: { [weak self] in self?.taskCreateTapped() }, + onOnlineTap: { [weak self] in self?.onlineTapped() } + ) + return cell + case .menu(let uri): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeMenuItemCell.reuseID, + for: indexPath + ) as! HomeMenuItemCell + if let menuItem = self.menuItem(for: uri) ?? self.displayMenuItems.first(where: { $0.uri == uri }) { + cell.configure(item: menuItem) + } + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard let self, + kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + section == .commonMenus, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + header.configure(title: "常用应用") + return header + } + } + + /// 根据当前角色与状态构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + if shouldShowWorkStatus { + snapshot.appendSections([.workStatus, .locationReport]) + snapshot.appendItems( + [.workStatus(isOnline: isOnline, secondsUntilReport: secondsUntilReport, reminderMinutes: reminderMinutes)], + toSection: .workStatus + ) + snapshot.appendItems([.locationReport], toSection: .locationReport) + } + + if isStoreManager, let store = appServices.accountContext.currentStore { + snapshot.appendSections([.storeInfo]) + snapshot.appendItems([.storeInfo(storeID: store.id)], toSection: .storeInfo) + } + + if shouldShowWorkStatus { + snapshot.appendSections([.quickActions]) + snapshot.appendItems([.quickActions(isOnline: isOnline)], toSection: .quickActions) + } + + snapshot.appendSections([.commonMenus]) + snapshot.appendItems( + displayMenuItems.map { HomeItem.menu(uri: $0.uri) }, + toSection: .commonMenus + ) + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 绑定 ViewModel 回调或数据。 + private func bindViewModel() { + viewModel.onChange = { [weak self] in + self?.applySnapshot() + } + } + + /// 监听权限与账号上下文变化并刷新菜单。 private func observeContextChanges() { let services = appServices services.permissionContext.onChange = { [weak self] in @@ -106,10 +281,11 @@ final class HomeViewController: UIViewController { } services.accountContext.onChange = { [weak self] in self?.updateScenicTitle() - self?.tableView.reloadData() + self?.applySnapshot() } } + /// 按当前角色权限重建首页菜单与常用应用。 private func rebuildMenus() { let services = appServices viewModel.buildMenus( @@ -117,9 +293,10 @@ final class HomeViewController: UIViewController { currentRoleId: services.permissionContext.currentRole?.id ) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) - tableView.reloadData() + applySnapshot() } + /// 刷新顶部景区名称展示。 private func updateScenicTitle() { let name = appServices.accountContext.currentScenic?.name ?? "请选择景区" scenicButton.configuration?.attributedTitle = AttributedString( @@ -130,25 +307,30 @@ final class HomeViewController: UIViewController { ) } + /// 当前登录角色 ID。 private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 部分精简角色不展示工作状态与位置上报区块。 private var shouldShowWorkStatus: Bool { guard let currentRoleId else { return true } return !minimalTopRoleIds.contains(currentRoleId) } + /// 是否为门店管理员角色(roleId = 46)。 private var isStoreManager: Bool { currentRoleId == 46 } + /// 常用应用展示项:优先用户自定义,不足 3 个时取权限菜单前 3 项,末尾固定「更多功能」。 private var displayMenuItems: [HomeMenuItem] { let selected = commonURIs.compactMap { menuItem(for: $0) } let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] } + /// 按 URI 解析并返回可用菜单项。 private func menuItem(for uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) @@ -160,31 +342,36 @@ final class HomeViewController: UIViewController { ) } - private var countdownDisplay: String { - let hours = secondsUntilReport / 3_600 - let minutes = (secondsUntilReport % 3_600) / 60 - let seconds = secondsUntilReport % 60 - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + /// 格式化倒计时展示文本。 + private func countdownText(seconds: Int) -> String { + let hours = seconds / 3_600 + let minutes = (seconds % 3_600) / 60 + let secs = seconds % 60 + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secs))" } - private var reminderText: String { - reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" + /// 格式化提前提醒展示文本。 + private func reminderText(minutes: Int) -> String { + minutes == 0 ? "不提醒" : "提前\(minutes)分钟" } + /// 在线状态下启动位置上报倒计时。 private func startCountdownTimerIfNeeded() { countdownTimer?.invalidate() guard isOnline else { return } countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self, self.isOnline, self.secondsUntilReport > 0 else { return } self.secondsUntilReport -= 1 - self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self.applySnapshot(animated: false) } } + /// 点击景区名称,跳转景区选择页。 @objc private func scenicTapped() { HomeMenuRouting.push(.scenicSelection, from: self) } + /// 切换在线 / 离线状态。 @objc private func onlineTapped() { let message = isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" @@ -200,147 +387,137 @@ final class HomeViewController: UIViewController { } else { self.countdownTimer?.invalidate() } - self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self.applySnapshot() }) present(alert, animated: true) } + /// 选择位置上报提前提醒时间。 @objc private func reminderTapped() { let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet) for minute in [0, 5, 10, 15, 30] { let title = minute == 0 ? "不提醒" : "\(minute)分钟" sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in self?.reminderMinutes = minute - self?.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self?.applySnapshot() }) } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } + /// 跳转位置上报页面。 @objc private func locationReportTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self) } + /// 跳转立即收款页面。 @objc private func paymentTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self) } + /// 跳转提交任务页面。 @objc private func taskCreateTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self) } } -// MARK: - UITableView +// MARK: - UICollectionViewDelegate -extension HomeViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - var count = 1 - if shouldShowWorkStatus { count += 2 } - if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 } - return count +extension HomeViewController: UICollectionViewDelegate { + /// 点击常用应用网格项,执行菜单路由。 + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .menu(let uri) = item, + let menuItem = displayMenuItems.first(where: { $0.uri == uri }) + else { return } + HomeMenuRouting.openMenu(menuItem, from: self) } +} - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 1 - } +// MARK: - Card Cells - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - if section == tableView.numberOfSections - 1 { - return "常用应用" - } - return nil - } +/// 在线状态卡片 Cell,展示在线切换、倒计时与提醒设置。 +private final class HomeWorkStatusCell: UICollectionViewCell { + static let reuseID = "HomeWorkStatusCell" - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == tableView.numberOfSections - 1 { - let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell - cell.configure(items: displayMenuItems) { [weak self] item in - self.flatMap { HomeMenuRouting.openMenu(item, from: $0) } - } - return cell - } + private let cardView = UIView() + private let onlineButton = UIButton(type: .system) + private let clockLabel = UILabel() + private let reminderButton = UIButton(type: .system) - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - cell.selectionStyle = .none - cell.contentView.subviews.forEach { $0.removeFromSuperview() } + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) - if shouldShowWorkStatus { - if indexPath.section == 0 { - cell.contentView.addSubview(makeStatusCard()) - } else if indexPath.section == 1 { - cell.contentView.addSubview(makeLocationCard()) - } else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore { - cell.contentView.addSubview(makeStoreCard(store)) - } else { - cell.contentView.addSubview(makeQuickActionsRow()) - } - } else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 { - cell.contentView.addSubview(makeStoreCard(store)) - } - - cell.contentView.subviews.first?.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets( - top: AppMetrics.Spacing.xxSmall, - left: AppMetrics.Spacing.pageHorizontal, - bottom: AppMetrics.Spacing.xxSmall, - right: AppMetrics.Spacing.pageHorizontal - )) - } - cell.backgroundColor = .clear - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == tableView.numberOfSections - 1 { return 240 } - if shouldShowWorkStatus { - switch indexPath.section { - case 0: return 100 - case 1: return 148 - case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88 - default: return 118 - } - } - if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 } - return UITableView.automaticDimension - } - - private func makeStatusCard() -> UIView { - let card = makeCardView() - - let onlineButton = UIButton(type: .system) - onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) - onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) - onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) onlineButton.layer.cornerRadius = 4 onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) - onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) - let clockLabel = UILabel() - clockLabel.text = " \(countdownDisplay)" clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) clockLabel.textColor = AppDesignUIKit.primary - let reminderButton = UIButton(type: .system) - reminderButton.setTitle(" \(reminderText)", for: .normal) reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal) - reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton]) stack.axis = .horizontal stack.distribution = .equalSpacing stack.alignment = .center - card.addSubview(stack) + cardView.addSubview(stack) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(15) } - return card } - private func makeLocationCard() -> UIView { - let card = makeCardView() + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置在线状态、倒计时与提醒,并绑定点击回调。 + func configure( + isOnline: Bool, + countdownText: String, + reminderText: String, + onOnlineTap: @escaping () -> Void, + onReminderTap: @escaping () -> Void + ) { + onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) + onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) + onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) + clockLabel.text = " \(countdownText)" + reminderButton.setTitle(" \(reminderText)", for: .normal) + + onlineButton.removeAction(identifiedBy: UIAction.Identifier("online"), for: .touchUpInside) + reminderButton.removeAction(identifiedBy: UIAction.Identifier("reminder"), for: .touchUpInside) + onlineButton.addAction(UIAction(identifier: UIAction.Identifier("online")) { _ in onOnlineTap() }, for: .touchUpInside) + reminderButton.addAction(UIAction(identifier: UIAction.Identifier("reminder")) { _ in onReminderTap() }, for: .touchUpInside) + } +} + +/// 位置上报卡片 Cell,展示打卡范围提示与立即上报入口。 +private final class HomeLocationReportCell: UICollectionViewCell { + static let reuseID = "HomeLocationReportCell" + + private let cardView = UIView() + private let actionButton = UIButton(type: .system) + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) let titleLabel = UILabel() titleLabel.text = "立即上报" @@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xxSmall - let actionButton = UIButton(type: .system) actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal) actionButton.tintColor = .white actionButton.backgroundColor = AppDesignUIKit.primary actionButton.layer.cornerRadius = 46 - actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside) - card.addSubview(textStack) - card.addSubview(actionButton) + cardView.addSubview(textStack) + cardView.addSubview(actionButton) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(15) make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12) @@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { make.centerY.equalToSuperview() make.width.height.equalTo(92) } - return card } - private func makeQuickActionsRow() -> UIView { - let stack = UIStackView() - stack.axis = .horizontal - stack.spacing = AppMetrics.Spacing.small - stack.distribution = .fillEqually - - stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped))) - stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped))) - stack.addArrangedSubview(quickActionButton( - icon: isOnline ? "wifi" : "wifi.slash", - title: isOnline ? "在线" : "离线", - action: #selector(onlineTapped), - active: isOnline - )) - return stack + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView { - let card = makeCardView() - card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white - - let iconView = UIImageView(image: UIImage(systemName: icon)) - iconView.tintColor = AppDesignUIKit.primary - iconView.contentMode = .scaleAspectFit - - let label = UILabel() - label.text = title - label.font = .systemFont(ofSize: AppMetrics.FontSize.body) - label.textAlignment = .center - - let stack = UIStackView(arrangedSubviews: [iconView, label]) - stack.axis = .vertical - stack.spacing = AppMetrics.Spacing.xSmall - stack.alignment = .center - card.addSubview(stack) - stack.snp.makeConstraints { make in - make.center.equalToSuperview() - } - iconView.snp.makeConstraints { make in - make.height.equalTo(34) - } - - let button = UIButton(type: .custom) - button.addTarget(self, action: action, for: .touchUpInside) - card.addSubview(button) - button.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - card.snp.makeConstraints { make in - make.height.equalTo(102) - } - return card + /// 配置立即上报按钮点击回调。 + func configure(onReportTap: @escaping () -> Void) { + actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside) + actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside) } +} - private func makeStoreCard(_ store: BusinessScope) -> UIView { - let card = makeCardView() +/// 门店信息卡片 Cell,展示当前门店名称与营业状态。 +private final class HomeStoreInfoCell: UICollectionViewCell { + static let reuseID = "HomeStoreInfoCell" + + private let cardView = UIView() + private let nameLabel = UILabel() + private let scenicLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) - let nameLabel = UILabel() - nameLabel.text = store.name nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold) - let scenicLabel = UILabel() - scenicLabel.text = appServices.accountContext.currentScenic?.name scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) scenicLabel.textColor = UIColor(hex: 0x7B8EAA) scenicLabel.numberOfLines = 2 @@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xSmall - card.addSubview(textStack) - card.addSubview(statusLabel) + cardView.addSubview(textStack) + cardView.addSubview(statusLabel) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) @@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { make.width.greaterThanOrEqualTo(52) make.height.equalTo(22) } - return card + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置门店名称与所属景区。 + func configure(storeName: String, scenicName: String?) { + nameLabel.text = storeName + scenicLabel.text = scenicName } } -// MARK: - Menu Grid Cell +/// 快捷操作行 Cell,展示收款、任务与在线状态入口。 +private final class HomeQuickActionsCell: UICollectionViewCell { + static let reuseID = "HomeQuickActionsCell" -private final class HomeMenuGridCell: UITableViewCell { - static let reuseID = "HomeMenuGridCell" + private let stackView = UIStackView() - private var onSelect: ((HomeMenuItem) -> Void)? - private var items: [HomeMenuItem] = [] - - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.minimumInteritemSpacing = 15 - layout.minimumLineSpacing = 15 - let view = UICollectionView(frame: .zero, collectionViewLayout: layout) - view.backgroundColor = .clear - view.isScrollEnabled = false - view.dataSource = self - view.delegate = self - view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) - return view - }() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(collectionView) - collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) - make.height.equalTo(220) + stackView.axis = .horizontal + stackView.spacing = AppMetrics.Spacing.small + stackView.distribution = .fillEqually + contentView.addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() } } @@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) { - self.items = items - self.onSelect = onSelect - collectionView.reloadData() - } -} - -extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - items.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell - cell.configure(item: items[indexPath.item]) - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - onSelect?(items[indexPath.item]) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width = (collectionView.bounds.width - 30) / 3 - return CGSize(width: width, height: 102) + /// 配置三个快捷操作按钮与点击回调。 + func configure( + isOnline: Bool, + onPaymentTap: @escaping () -> Void, + onTaskCreateTap: @escaping () -> Void, + onOnlineTap: @escaping () -> Void + ) { + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + stackView.addArrangedSubview(quickActionCard( + icon: "qrcode", + title: "立即收款", + active: false, + action: onPaymentTap + )) + stackView.addArrangedSubview(quickActionCard( + icon: "checklist.checked", + title: "提交任务", + active: false, + action: onTaskCreateTap + )) + stackView.addArrangedSubview(quickActionCard( + icon: isOnline ? "wifi" : "wifi.slash", + title: isOnline ? "在线" : "离线", + active: isOnline, + action: onOnlineTap + )) + } + + /// 构建单个快捷操作卡片。 + private func quickActionCard( + icon: String, + title: String, + active: Bool, + action: @escaping () -> Void + ) -> UIView { + let card = UIView() + card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white + card.layer.cornerRadius = 8 + + let iconView = UIImageView(image: UIImage(systemName: icon)) + iconView.tintColor = AppDesignUIKit.primary + iconView.contentMode = .scaleAspectFit + + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.body) + label.textAlignment = .center + + let innerStack = UIStackView(arrangedSubviews: [iconView, label]) + innerStack.axis = .vertical + innerStack.spacing = AppMetrics.Spacing.xSmall + innerStack.alignment = .center + card.addSubview(innerStack) + innerStack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.height.equalTo(34) + } + + let button = UIButton(type: .custom) + button.addAction(UIAction { _ in action() }, for: .touchUpInside) + card.addSubview(button) + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + card.snp.makeConstraints { make in + make.height.equalTo(102) + } + return card } } +/// 首页菜单网格单项 Cell,负责图标与标题展示。 private final class HomeMenuItemCell: UICollectionViewCell { static let reuseID = "HomeMenuItemCell" private let iconView = UIImageView() private let titleLabel = UILabel() + /// 初始化实例。 override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white @@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + /// 配置菜单项图标与标题。 func configure(item: HomeMenuItem) { titleLabel.text = item.title let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) @@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell { iconView.tintColor = AppDesign.primary } } + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift index c0d3132..3563e05 100644 --- a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift +++ b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift @@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController { private let rulesLabel = UILabel() private let activityIndicator = UIActivityIndicatorView(style: .medium) + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "邀请摄影师" @@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController { Task { await viewModel.reload(api: services.inviteAPI) } } + /// 初始化UI相关 UI 或状态。 private func setupUI() { navigationItem.rightBarButtonItems = [ UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)), @@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController { contentStack.addArrangedSubview(rulesLabel) } + /// render 业务逻辑。 private func render() { activityIndicator.isHidden = !viewModel.loading if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } @@ -76,7 +79,9 @@ final class PhotographerInviteViewController: UIViewController { } } + /// 复制验证码。 @objc private func copyCode() { viewModel.copyInviteCode() } + /// 复制链接。 @objc private func copyURL() { viewModel.copyInviteUrl() } } @@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController { private let viewModel = InviteRecordViewModel() private let summaryLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "邀请记录" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { summaryLabel.font = .systemFont(ofSize: 14) summaryLabel.textColor = AppDesign.textSecondary @@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController { tableView.tableHeaderView = summaryLabel } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.displayRows.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let row = viewModel.displayRows[indexPath.row] cell.configure( @@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload( inviteAPI: services.inviteAPI, @@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController { updateSummary() } + /// 更新Summary状态。 private func updateSummary() { summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)" navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户" } + /// toggleTab相关逻辑。 @objc private func toggleTab() { Task { let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite diff --git a/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift index 881db5f..5c176a0 100644 --- a/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift +++ b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift @@ -61,6 +61,7 @@ final class PhotographerInviteViewModel { return UIImage(cgImage: cgImage) } + /// 清空。 private func clear() { inviteCode = "" inviteUrl = "" @@ -128,6 +129,7 @@ final class InviteRecordViewModel { await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) } + /// 加载InviteRows数据。 private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws { let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize) let mapped = users.map { @@ -152,6 +154,7 @@ final class InviteRecordViewModel { hasMore = users.count >= invitePageSize } + /// 加载RewardRows数据。 private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws { let response = try await api.walletEarningDetail( startDate: "2025-01-01", @@ -183,6 +186,7 @@ final class InviteRecordViewModel { hasMore = rewardRows.count < response.total && !mapped.isEmpty } + /// 清空。 private func clear() { totalRewardText = "¥ 0.00" withdrawableText = "¥ 0.00" diff --git a/suixinkan_ios/Features/Live/API/LiveAPI.swift b/suixinkan_ios/Features/Live/API/LiveAPI.swift index 862ad1d..3f8eab0 100644 --- a/suixinkan_ios/Features/Live/API/LiveAPI.swift +++ b/suixinkan_ios/Features/Live/API/LiveAPI.swift @@ -10,17 +10,29 @@ import Foundation /// 直播服务协议,定义直播管理和直播相册接口能力。 @MainActor protocol LiveServing { + /// 直播列表相关逻辑。 func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse + /// 直播详情相关逻辑。 func liveDetail(liveId: Int) async throws -> LiveEntity + /// 直播创建相关逻辑。 func liveCreate(_ request: LiveCreateRequest) async throws + /// 直播启动相关逻辑。 func liveStart(liveId: Int) async throws + /// 直播停止相关逻辑。 func liveStop(liveId: Int) async throws + /// 直播Finish相关逻辑。 func liveFinish(liveId: Int) async throws + /// 直播Set推送Mode相关逻辑。 func liveSetPushMode(liveId: Int, mode: Int) async throws + /// 直播相册列表相关逻辑。 func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse + /// 直播相册创建文件夹相关逻辑。 func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws + /// 直播相册删除文件夹相关逻辑。 func liveAlbumDeleteFolder(folderId: Int) async throws + /// 直播相册文件夹详情相关逻辑。 func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem + /// 直播相册删除Files相关逻辑。 func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws } diff --git a/suixinkan_ios/Features/Live/Models/LiveModels.swift b/suixinkan_ios/Features/Live/Models/LiveModels.swift index 867c15c..468aa83 100644 --- a/suixinkan_ios/Features/Live/Models/LiveModels.swift +++ b/suixinkan_ios/Features/Live/Models/LiveModels.swift @@ -14,6 +14,7 @@ struct LiveListResponse: Decodable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case total @@ -21,6 +22,7 @@ struct LiveListResponse: Decodable { case pageSize = "page_size" } + /// 初始化实例。 init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) { self.items = items self.total = total @@ -28,6 +30,7 @@ struct LiveListResponse: Decodable { self.pageSize = pageSize } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? [] @@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { let manualPushState: Int let viewsCount: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case title @@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { case viewsCount = "views_count" } + /// 初始化实例。 init( id: Int = 0, title: String = "", @@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { self.viewsCount = viewsCount } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable { let title: String let coverImg: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case title @@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable { struct LiveControlRequest: Encodable, Equatable { let liveId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case liveId = "live_id" } @@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable { let liveId: Int let manualPushMode: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case liveId = "live_id" case manualPushMode = "manual_push_mode" @@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable { let total: Int let totalPages: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case page @@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable { case totalPages = "total_pages" } + /// 初始化实例。 init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) { self.items = items self.page = page @@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable { self.totalPages = totalPages } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? [] @@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { let creator: String let items: [LiveAlbumFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case albumId = "album_id" @@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { case items } + /// 初始化实例。 init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) { self.id = id self.albumId = albumId @@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { self.items = items } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { let size: Int64 let coverImg: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case url @@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { case coverImg = "cover_img" } + /// 初始化实例。 init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) { self.id = id self.url = url @@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { self.coverImg = coverImg } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable { let name: String let items: [LiveAlbumCreateFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case name @@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable { let size: Int64 let coverImg: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case url case type @@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable { struct LiveAlbumDeleteFolderRequest: Encodable, Equatable { let folderId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case folderId = "folder_id" } @@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable { let folderId: Int let fileIds: [Int] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case folderId = "folder_id" case fileIds = "file_ids" @@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable { let size: Int64 var uploadedURL: String? + /// 初始化实例。 init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) { self.id = id self.data = data @@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable { } private extension KeyedDecodingContainer { + /// 直播解码宽松字符串相关逻辑。 func liveDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -378,6 +402,7 @@ private extension KeyedDecodingContainer { return "" } + /// 直播解码宽松整数相关逻辑。 func liveDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift index f2ae87f..5b12d67 100644 --- a/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift +++ b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift @@ -11,14 +11,17 @@ import UIKit final class LiveManagementViewController: ModuleTableViewController { private let viewModel = LiveManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "直播管理" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure( @@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.items.count - 2 else { return } Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) } @@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {} final class LiveAlbumViewController: ModuleTableViewController { private let viewModel = LiveAlbumViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "直播相册" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) } diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift index c05f12b..84eda98 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift @@ -10,10 +10,12 @@ import Foundation /// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。 enum LivePlaybackURLResolver { + /// playable链接相关逻辑。 static func playableURL(from live: LiveEntity) -> URL? { playableURL(from: live.playbackURLCandidates) } + /// playable链接相关逻辑。 static func playableURL(from candidates: [String]) -> URL? { for candidate in candidates { if let url = playableURL(from: candidate) { @@ -23,6 +25,7 @@ enum LivePlaybackURLResolver { return nil } + /// playable链接相关逻辑。 static func playableURL(from rawValue: String) -> URL? { let value = rawValue.liveTrimmed guard !value.isEmpty, let url = URL(string: value) else { return nil } @@ -65,6 +68,7 @@ final class LivePlaybackViewModel { return false } + /// 初始化实例。 init(live: LiveEntity? = nil, urlString: String? = nil) { if let live { load(live: live) @@ -73,14 +77,17 @@ final class LivePlaybackViewModel { } } + /// 加载数据。 func load(live: LiveEntity) { load(url: LivePlaybackURLResolver.playableURL(from: live)) } + /// 加载数据。 func load(urlString: String) { load(url: LivePlaybackURLResolver.playableURL(from: urlString)) } + /// play 业务逻辑。 func play() { guard let url = playableURL else { return } if player == nil { @@ -90,12 +97,14 @@ final class LivePlaybackViewModel { state = .playing(url) } + /// pause 业务逻辑。 func pause() { guard let url = playableURL else { return } player?.pause() state = .ready(url) } + /// 刷新。 func reload() { guard let url = playableURL else { return } releasePlayer() @@ -103,6 +112,7 @@ final class LivePlaybackViewModel { state = .ready(url) } + /// release 业务逻辑。 func release() { releasePlayer() if let url = playableURL { @@ -112,6 +122,7 @@ final class LivePlaybackViewModel { } } + /// 加载数据。 private func load(url: URL?) { releasePlayer() guard let url else { @@ -124,6 +135,7 @@ final class LivePlaybackViewModel { state = .ready(url) } + /// releasePlayer相关逻辑。 private func releasePlayer() { player?.pause() player = nil diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift index 12441c0..fe9c756 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift @@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable { protocol LivePushAdapter { var name: String { get } var isAvailable: Bool { get } + /// prepare 业务逻辑。 func prepare(pushURL: URL) async throws + /// start 业务逻辑。 func start() async throws + /// stop 业务逻辑。 func stop() async throws + /// dispose 业务逻辑。 func dispose() async } /// 权限提供者协议,隔离 AVFoundation 便于测试。 protocol LivePermissionProviding { + /// camera权限相关逻辑。 func cameraPermission() async -> LivePushPermissionState + /// microphone权限相关逻辑。 func microphonePermission() async -> LivePushPermissionState } /// 网络监听协议,隔离 NWPathMonitor 便于测试。 protocol LiveNetworkMonitoring: AnyObject { var currentState: LivePushNetworkState { get } + /// start 业务逻辑。 func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) + /// stop 业务逻辑。 func stop() } +/// LivePushReadiness错误类型定义。 enum LivePushReadinessError: LocalizedError, Equatable { case missingPushURL case invalidPushURL @@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable { } } +/// UnsupportedLivePushAdapter,业务类型定义。 struct UnsupportedLivePushAdapter: LivePushAdapter { let name = "未接入推流 SDK" let isAvailable = false + /// prepare 业务逻辑。 func prepare(pushURL: URL) async throws { throw LivePushReadinessError.sdkUnavailable } + /// start 业务逻辑。 func start() async throws { throw LivePushReadinessError.sdkUnavailable } + /// stop 业务逻辑。 func stop() async throws {} + /// dispose 业务逻辑。 func dispose() async {} } +/// SystemLivePermission能力提供者,封装外部依赖。 struct SystemLivePermissionProvider: LivePermissionProviding { + /// camera权限相关逻辑。 func cameraPermission() async -> LivePushPermissionState { await permission(for: .video) } + /// microphone权限相关逻辑。 func microphonePermission() async -> LivePushPermissionState { await permission(for: .audio) } + /// permission 业务逻辑。 private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState { switch AVCaptureDevice.authorizationStatus(for: mediaType) { case .authorized: @@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding { } } +/// SystemLiveNetworkMonitor 类型定义。 final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { var onChange: (() -> Void)? @@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { private let queue = DispatchQueue(label: "com.suixinkan.live.network") private(set) var currentState: LivePushNetworkState = .unknown + /// start 业务逻辑。 func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { monitor.pathUpdateHandler = { [weak self] path in let state = Self.state(from: path) @@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { monitor.start(queue: queue) } + /// stop 业务逻辑。 func stop() { monitor.cancel() } + /// state 业务逻辑。 private static func state(from path: NWPath) -> LivePushNetworkState { guard path.status == .satisfied else { return .unavailable } if path.usesInterfaceType(.wifi) { return .wifi } @@ -177,6 +199,7 @@ final class LivePushReadinessViewModel { private let adapter: any LivePushAdapter private var pushURL: URL? + /// 初始化实例。 init( permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(), @@ -190,6 +213,7 @@ final class LivePushReadinessViewModel { self.networkState = networkMonitor.currentState } + /// 配置展示内容。 func configure(pushURL rawValue: String) { let value = rawValue.liveTrimmed guard !value.isEmpty else { @@ -206,6 +230,7 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// startMonitoring相关逻辑。 func startMonitoring() { networkState = networkMonitor.currentState networkMonitor.start { [weak self] state in @@ -215,10 +240,12 @@ final class LivePushReadinessViewModel { } } + /// stopMonitoring相关逻辑。 func stopMonitoring() { networkMonitor.stop() } + /// refreshPermissions相关逻辑。 func refreshPermissions() async { async let camera = permissionProvider.cameraPermission() async let microphone = permissionProvider.microphonePermission() @@ -226,6 +253,7 @@ final class LivePushReadinessViewModel { microphonePermission = await microphone } + /// runDiagnostics相关逻辑。 func runDiagnostics() throws { do { try validateReadiness() @@ -240,6 +268,7 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// prepare 业务逻辑。 func prepare() async throws { try validateReadiness() guard let pushURL else { throw LivePushReadinessError.missingPushURL } @@ -253,6 +282,7 @@ final class LivePushReadinessViewModel { } } + /// start推送相关逻辑。 func startPush() async throws { try validateReadiness() guard adapter.isAvailable else { @@ -265,11 +295,13 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// stop推送相关逻辑。 func stopPush() async { try? await adapter.stop() running = false } + /// dispose 业务逻辑。 func dispose() async { stopMonitoring() await adapter.dispose() @@ -277,6 +309,7 @@ final class LivePushReadinessViewModel { prepared = false } + /// 校验Readiness输入或状态。 private func validateReadiness() throws { guard pushURL != nil else { throw LivePushReadinessError.missingPushURL diff --git a/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift index a5ec54b..960bc5c 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift @@ -104,6 +104,7 @@ final class LiveManagementViewModel { await reload(api: api, scenicId: scenicId, showLoading: false) } + /// 加载Page数据。 private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize) if page == 1 { @@ -118,6 +119,7 @@ final class LiveManagementViewModel { hasMore = items.count < total } + /// 重置状态。 private func reset() { clearListAndDetail() loading = false @@ -125,6 +127,7 @@ final class LiveManagementViewModel { errorMessage = nil } + /// 清空列表并详情。 private func clearListAndDetail() { items = [] detail = nil @@ -143,6 +146,7 @@ final class LiveDetailViewModel { var actionInFlight = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } + /// 初始化实例。 init(detail: LiveEntity) { self.detail = detail } @@ -265,6 +269,7 @@ final class LiveAlbumViewModel { await reload(api: api, scenicId: scenicId, showLoading: false) } + /// 加载Page数据。 private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { let response = try await api.liveAlbumList( scenicId: scenicId, @@ -284,6 +289,7 @@ final class LiveAlbumViewModel { hasMore = folders.count < total } + /// 重置状态。 private func reset() { clearFolders() loading = false @@ -291,6 +297,7 @@ final class LiveAlbumViewModel { errorMessage = nil } + /// 清空Folders。 private func clearFolders() { folders = [] page = 1 @@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel { var loading = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } + /// 初始化实例。 init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { self.folderId = folderId currentIndex = max(startIndex, 0) diff --git a/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift index f22868a..e0b854c 100644 --- a/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift +++ b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift @@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable { let expired: Int let status: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case staffId = "staff_id" case expired @@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable { let remark: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case staffId = "staff_id" diff --git a/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift index 999ddd0..7e72ef6 100644 --- a/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift +++ b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class LocationReportViewController: ModuleTableViewController { private let viewModel = LocationReportViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "位置上报" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController { wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? 3 : 1 } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "状态" : "操作" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 1 { Task { _ = await viewModel.setOnline( @@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入") } + /// 提交Report。 @objc private func submitReport() { Task { _ = await viewModel.submit( @@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {} final class LocationReportHistoryViewController: ModuleTableViewController { private let viewModel = LocationReportHistoryViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "上报历史" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = viewModel.items[indexPath.row] cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.items.count - 2 else { return } Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) } diff --git a/suixinkan_ios/Features/Main/Main.md b/suixinkan_ios/Features/Main/Main.md index caccf7b..af5e90a 100644 --- a/suixinkan_ios/Features/Main/Main.md +++ b/suixinkan_ios/Features/Main/Main.md @@ -2,14 +2,9 @@ ## 模块职责 -Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。 +Main 模块负责登录后的主界面 Tab 容器。 -主界面由 `MainTabsView` 承载: -- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。 -- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。 -- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。 -- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。 -- 每个 Tab 的导航路径由 `AppRouter` 单独保存。 +主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。 ## Tab 结构 @@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 - `statistics`:数据 - `profile`:我的 -`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。 +各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。 ## 导航流程 -1. `MainTabsView` 从 Environment 读取 `AppRouter`。 -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。 +1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。 +2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。 +3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。 +4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。 +5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。 -## 自定义 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 顶起。 +- 角标展示在订单 Tab 的 `tabBarItem.badgeValue` 上。 +- 账号景区或门店变化、切换到订单 Tab 时会刷新角标。 -系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。 +## 扫码核销 + +系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。 ## 后续迁移规则 迁移新页面时: -- 优先替换对应 Tab 的 RootView。 -- 保持每个 Tab 自己的 `NavigationStack`。 +- 优先替换对应 Tab 的根 ViewController。 +- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。 - 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 - 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。 - Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 - 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。 -- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。 - 真实业务页面接入后,应同步补充该模块文档和单元测试。 diff --git a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift index d8a732c..0c14da3 100644 --- a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift +++ b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift @@ -3,24 +3,22 @@ // suixinkan // -import SnapKit import UIKit @MainActor -/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。 -final class MainTabBarController: UIViewController { +/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。 +final class MainTabBarController: UITabBarController, UITabBarControllerDelegate { private let services: AppServices private let badgeViewModel = MainTabBadgeViewModel() - private let contentContainer = UIView() - private let customTabBar = CustomMainTabBarView() - private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] - private var loadedTabs: Set = [.home] + private var isSyncingTabSelection = false + /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) + delegate = self } @available(*, unavailable) @@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white - configureLayout() + configureTabBarAppearance() + configureTabs() bindRouter() bindBadgeViewModel() bindAccountContext() - switchToTab(services.appRouter.selectedTab, animated: false) + syncSelectedTabFromRouter(animated: false) Task { await refreshOrderBadge() } #if DEBUG Task { await AppUITestRouteDriver.applyIfNeeded(services: services) } #endif } - /// 控制自定义 TabBar 显隐,push 子页面时隐藏。 - func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) { - let updates = { - self.customTabBar.alpha = hidden ? 0 : 1 - self.customTabBar.isUserInteractionEnabled = !hidden - } - - guard animated else { - updates() - return - } - - UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates) + /// 配置系统 TabBar 外观。 + private func configureTabBarAppearance() { + tabBar.tintColor = AppDesign.primary + tabBar.backgroundColor = .white } - private func configureLayout() { - view.addSubview(contentContainer) - view.addSubview(customTabBar) - - customTabBar.onTabSelected = { [weak self] tab in - self?.services.appRouter.select(tab) - } - customTabBar.onScanTapped = { [weak self] in - self?.presentScanner() - } - - contentContainer.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.bottom.equalTo(customTabBar.snp.top) - } - - customTabBar.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview() - make.bottom.equalTo(view.safeAreaLayoutGuide) - make.height.equalTo(72) + /// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。 + private func configureTabs() { + viewControllers = AppTab.allCases.map { tab in + let navigationController = TabNavigationController(tab: tab, services: services) + navigationController.tabBarItem = tab.makeTabBarItem() + tabNavigationControllers[tab] = navigationController + return navigationController } } + /// 绑定 Router 回调,同步程序化 Tab 切换。 private func bindRouter() { services.appRouter.onChange = { [weak self] in self?.handleRouterChange() } } + /// 绑定角标 ViewModel 回调。 private func bindBadgeViewModel() { badgeViewModel.onChange = { [weak self] in self?.updateOrderBadge() } } + /// 绑定账号上下文,景区或门店变化时刷新角标。 private func bindAccountContext() { services.accountContext.onChange = { [weak self] in Task { await self?.refreshOrderBadge() } } } + /// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。 private func handleRouterChange() { - switchToTab(services.appRouter.selectedTab, animated: false) + syncSelectedTabFromRouter(animated: false) if services.appRouter.selectedTab == .orders { Task { await refreshOrderBadge() } } } - private func switchToTab(_ tab: AppTab, animated: Bool) { - loadedTabs.insert(tab) - customTabBar.selectedTab = tab + /// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。 + private func syncSelectedTabFromRouter(animated: Bool) { + guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return } + guard selectedIndex != index else { return } - let navigationController = navigationController(for: tab) - for child in children where child !== navigationController { - child.willMove(toParent: nil) - child.view.removeFromSuperview() - child.removeFromParent() - } - - addChild(navigationController) - contentContainer.addSubview(navigationController.view) - navigationController.view.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - navigationController.didMove(toParent: self) - - let isRoot = navigationController.viewControllers.count <= 1 - setCustomTabBarHidden(!isRoot, animated: animated) - } - - private func navigationController(for tab: AppTab) -> TabNavigationController { - if let existing = tabNavigationControllers[tab] { - return existing - } - - let navigationController = TabNavigationController(tab: tab, services: services) - navigationController.tabBarHost = self - tabNavigationControllers[tab] = navigationController - return navigationController + isSyncingTabSelection = true + selectedIndex = index + isSyncingTabSelection = false } + /// 更新订单 Tab 待核销角标。 private func updateOrderBadge() { - guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else { - customTabBar.orderBadgeText = nil - return + let badgeValue: String? + if let count = badgeViewModel.pendingWriteOffCount, count > 0 { + badgeValue = "\(count)" + } else { + badgeValue = nil } - customTabBar.orderBadgeText = "\(count)" + tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue } + /// 刷新订单 Tab 待核销角标数据。 private func refreshOrderBadge() async { await badgeViewModel.refreshPendingWriteOffCount( api: services.ordersAPI, @@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController { ) } - private func presentScanner() { - let scanner = OrderCodeScannerViewController() - scanner.onScanResult = { [weak self] result in - guard let self else { return } - switch result { - case .success(let rawCode): - dismiss(animated: true) { - self.services.appRouter.routeToOrderVerification(scannedCode: rawCode) - } - case .failure(let error): - showToast(error.localizedDescription) - } + /// 用户点击 TabBar 时同步选中状态到 `AppRouter`。 + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + guard !isSyncingTabSelection, + let navigationController = viewController as? TabNavigationController else { return } + + services.appRouter.select(navigationController.appTab) + if navigationController.appTab == .orders { + Task { await refreshOrderBadge() } } - let navigation = UINavigationController(rootViewController: scanner) - navigation.modalPresentationStyle = .fullScreen - present(navigation, animated: true) - } -} - -/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。 -private final class CustomMainTabBarView: UIView { - - var selectedTab: AppTab = .home { - didSet { refreshSelection() } - } - - var orderBadgeText: String? { - didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil } - } - - var onTabSelected: ((AppTab) -> Void)? - var onScanTapped: (() -> Void)? - - private struct TabItem { - let tab: AppTab - let title: String - let selectedImage: String - let unselectedImage: String - } - - private let items: [TabItem] = [ - .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") - ] - - private var tabButtons: [AppTab: UIButton] = [:] - private var tabTitleLabels: [AppTab: UILabel] = [:] - private let ordersBadgeLabel = UILabel() - private let topDivider = UIView() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .white - configureViews() - refreshSelection() - } - - required init?(coder: NSCoder) { - nil - } - - private func configureViews() { - topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04) - addSubview(topDivider) - topDivider.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.height.equalTo(0.5) - } - - let leftStack = UIStackView() - leftStack.axis = .horizontal - leftStack.distribution = .fillEqually - leftStack.spacing = 0 - - let rightStack = UIStackView() - rightStack.axis = .horizontal - rightStack.distribution = .fillEqually - rightStack.spacing = 0 - - for (index, item) in items.enumerated() { - let buttonContainer = makeTabButton(for: item) - if index < 2 { - leftStack.addArrangedSubview(buttonContainer) - } else { - rightStack.addArrangedSubview(buttonContainer) - } - } - - let scanButton = UIButton(type: .system) - scanButton.backgroundColor = AppDesign.primary - scanButton.layer.cornerRadius = 29 - scanButton.tintColor = .white - scanButton.setImage( - UIImage(systemName: "qrcode.viewfinder")?.withConfiguration( - UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) - ), - for: .normal - ) - scanButton.accessibilityLabel = "扫码核销" - scanButton.accessibilityIdentifier = "main.scan" - scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside) - - let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack]) - row.axis = .horizontal - row.alignment = .center - row.spacing = 0 - addSubview(row) - - scanButton.snp.makeConstraints { make in - make.width.equalTo(74) - make.height.equalTo(64) - } - - row.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(22) - make.top.bottom.equalToSuperview() - } - - leftStack.snp.makeConstraints { make in - make.width.equalTo(rightStack) - } - } - - private func makeTabButton(for item: TabItem) -> UIView { - let container = UIView() - - let button = UIButton(type: .custom) - button.accessibilityLabel = item.title - button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)" - button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0 - button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) - tabButtons[item.tab] = button - - let titleLabel = UILabel() - titleLabel.text = item.title - titleLabel.font = .systemFont(ofSize: 12) - titleLabel.textAlignment = .center - tabTitleLabels[item.tab] = titleLabel - - container.addSubview(button) - container.addSubview(titleLabel) - - button.snp.makeConstraints { make in - make.top.equalToSuperview().offset(8) - make.centerX.equalToSuperview() - make.width.height.equalTo(24) - } - - titleLabel.snp.makeConstraints { make in - make.top.equalTo(button.snp.bottom).offset(3) - make.centerX.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() - } - - if item.tab == .orders { - ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold) - ordersBadgeLabel.textColor = .white - ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444) - ordersBadgeLabel.textAlignment = .center - ordersBadgeLabel.layer.cornerRadius = 7.5 - ordersBadgeLabel.clipsToBounds = true - ordersBadgeLabel.isHidden = true - container.addSubview(ordersBadgeLabel) - ordersBadgeLabel.snp.makeConstraints { make in - make.top.equalTo(button).offset(-4) - make.leading.equalTo(button.snp.trailing).offset(-6) - make.height.equalTo(15) - make.width.greaterThanOrEqualTo(15) - } - } - - return container - } - - private func refreshSelection() { - for item in items { - let isSelected = item.tab == selectedTab - let imageName = isSelected ? item.selectedImage : item.unselectedImage - tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal) - tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3) - tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular) - } - } - - @objc private func tabTapped(_ sender: UIButton) { - guard sender.tag >= 0, sender.tag < items.count else { return } - onTabSelected?(items[sender.tag].tab) - } - - @objc private func scanTapped() { - onScanTapped?() } } diff --git a/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift index 2ec5edb..0de7990 100644 --- a/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift +++ b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift @@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController { private let pageTitle: String + /// 初始化实例。 init(title: String) { pageTitle = title super.init(nibName: nil, bundle: nil) @@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) diff --git a/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift index 04122e8..70b4fd6 100644 --- a/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift +++ b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift @@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable { let lastId: Int let items: [MessageEntity] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case hasMore = "has_more" case lastId = "last_id" @@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable { let createdAt: String let isRead: Bool + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case type diff --git a/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift index dc583e2..c57bad1 100644 --- a/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift +++ b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift @@ -11,6 +11,7 @@ import UIKit final class MessageCenterViewController: ModuleTableViewController { private let viewModel = MessageCenterViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "消息中心" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.messages.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.messages[indexPath.row] cell.configure( @@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController { cell.accessoryType = item.isRead ? .none : .disclosureIndicator } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.messages[indexPath.row] Task { @@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { isLoading = viewModel.messages.isEmpty await viewModel.reloadFirstPage(api: services.messageCenterAPI) isLoading = false } + /// mark全部Read相关逻辑。 @objc private func markAllRead() { Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) } } + /// 更新BarButtons状态。 private func updateBarButtons() { navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty } + /// 展示Alert。 private func showAlert(title: String, message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "知道了", style: .default)) diff --git a/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift index d92287a..b481106 100644 --- a/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift +++ b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift @@ -146,6 +146,7 @@ final class MessageCenterViewModel { } } + /// deduplicated并排序相关逻辑。 private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] { var unique: [String: MessageItem] = [:] for item in source { @@ -154,6 +155,7 @@ final class MessageCenterViewModel { return unique.values.sorted { $0.time > $1.time } } + /// 清空Messages。 private func clearMessages() { messages = [] hasMoreMessages = false diff --git a/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift index 9e62124..1703d03 100644 --- a/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift +++ b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift @@ -10,7 +10,9 @@ import Foundation /// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。 @MainActor protocol OperatingAreaServing { + /// store营业Area相关逻辑。 func storeBusinessArea(storeId: Int) async throws -> ListPayload + /// scenicAdmin营业Area相关逻辑。 func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload } diff --git a/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift index 6ed6754..f226269 100644 --- a/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift +++ b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift @@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { let typeText: String let auditStatusText: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { case auditStatusText = "audit_status_text" } + /// 初始化实例。 init( id: Int = 0, name: String = "", @@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { self.auditStatusText = auditStatusText } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 @@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable { case array([OperatingMapArea]) case object([String: OperatingMapArea]) + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable { return fromJSONObject(raw) } + /// fromJSONObject相关逻辑。 private static func fromJSONObject(_ value: Any) -> OperatingMapArea { switch value { case is NSNull: @@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable { let points: [OperatingGeoPoint] let isCurrentStore: Bool + /// 初始化实例。 init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { self.id = "\(itemId)-\(ringIndex)" self.itemId = itemId @@ -184,6 +190,7 @@ enum OperatingAreaParser { return parseElement(area) } + /// 解析Element数据。 private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { switch value { case .null, .bool, .number: @@ -199,6 +206,7 @@ enum OperatingAreaParser { } } + /// 解析Object数据。 private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { let type: String? if case let .string(rawType)? = object["type"] { @@ -231,6 +239,7 @@ enum OperatingAreaParser { } } + /// 解析ArrayRoot数据。 private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { guard let first = array.first else { return [] } switch first { @@ -257,6 +266,7 @@ enum OperatingAreaParser { } } + /// 解析ObjectRing数据。 private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in guard case let .object(object) = element else { return nil } @@ -265,6 +275,7 @@ enum OperatingAreaParser { return points.count >= 3 ? points : nil } + /// 解析Ring数据。 private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in switch element { @@ -283,6 +294,7 @@ enum OperatingAreaParser { return points.count >= 3 ? points : nil } + /// point从对象相关逻辑。 private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue @@ -290,6 +302,7 @@ enum OperatingAreaParser { return OperatingGeoPoint(latitude: lat, longitude: lng) } + /// 规范化Pair格式。 private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { if looksLikeLngLatPair(lng: a, lat: b) { return OperatingGeoPoint(latitude: b, longitude: a) @@ -303,24 +316,29 @@ enum OperatingAreaParser { return OperatingGeoPoint(latitude: a, longitude: b) } + /// looksLikeLngLat坐标对相关逻辑。 private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) } + /// looksLikeLatLng坐标对相关逻辑。 private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) } } +/// 结构体,封装数据实体。 private struct OperatingDynamicCodingKey: CodingKey { let stringValue: String let intValue: Int? + /// 初始化实例。 init?(stringValue: String) { self.stringValue = stringValue intValue = nil } + /// 初始化实例。 init?(intValue: Int) { stringValue = String(intValue) self.intValue = intValue @@ -341,6 +359,7 @@ private extension OperatingMapArea { } private extension KeyedDecodingContainer { + /// operating解码宽松字符串相关逻辑。 func operatingDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -357,6 +376,7 @@ private extension KeyedDecodingContainer { return "" } + /// operating解码宽松整数相关逻辑。 func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift index bc58b77..32839e5 100644 --- a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift +++ b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift @@ -1,37 +1,146 @@ // // OperatingAreaViewController.swift -// suixinkan -// -// Created by Codex on 2026/6/26. +// suixinkan_ios // +import SnapKit import UIKit -/// 运营区域列表页。 -final class OperatingAreaViewController: ModuleTableViewController { +/// 运营区域页:汇总信息、围栏地图与区域列表。 +final class OperatingAreaViewController: UIViewController { private let viewModel = OperatingAreaViewModel() + private let mapView = OperatingAreaMapView() + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let summaryLabel = UILabel() + private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash") + private var dataSource: UITableViewDiffableDataSource! + private var services: AppServices { AppServices.shared } + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { - title = "运营区域" super.viewDidLoad() - wireViewModel(viewModel) { } + title = "运营区域" + view.backgroundColor = UIColor(hex: 0xF5F7FA) + setupLayout() + wireViewModel() + Task { await reload(showLoading: true) } } - override func tableRowCount() -> Int { - viewModel.items.count + private func setupLayout() { + summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + summaryLabel.textColor = AppDesign.textSecondary + summaryLabel.numberOfLines = 0 + + mapView.snp.makeConstraints { make in + make.height.equalTo(320) + } + + blockReasonView.isHidden = true + + tableView.register(TitleSubtitleTableViewCell.self, forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier) + tableView.refreshControl = UIRefreshControl() + tableView.refreshControl?.addTarget(self, action: #selector(onPullRefresh), for: .valueChanged) + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in + guard let self else { return UITableViewCell() } + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + guard let item = self.viewModel.items.first(where: { String($0.id) == itemID }) else { + return cell + } + let ringCount = self.viewModel.fenceRings.filter { $0.itemId == item.id }.count + cell.configure( + title: item.name.isEmpty ? "未命名区域" : item.name, + subtitle: [item.typeText, item.statusText].filter { !$0.isEmpty }.joined(separator: " · "), + detail: "围栏 \(ringCount) 个" + ) + return cell + } + + let headerStack = UIStackView(arrangedSubviews: [summaryLabel, blockReasonView, mapView]) + headerStack.axis = .vertical + headerStack.spacing = AppMetrics.Spacing.medium + + let headerContainer = UIView() + headerContainer.addSubview(headerStack) + headerStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + } + headerContainer.layoutIfNeeded() + let height = headerStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + headerContainer.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: height + 24) + tableView.tableHeaderView = headerContainer + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } } - override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { - let item = viewModel.items[indexPath.row] - cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组") + private func wireViewModel() { + viewModel.onChange = { [weak self] in + self?.render() + } } - override func reloadContent() async { + private func render() { + summaryLabel.text = "\(viewModel.title) · \(scopeText()) · 区域 \(viewModel.summary.areaCount) · 围栏 \(viewModel.summary.fenceCount)" + + if let reason = viewModel.blockReason { + blockReasonView.isHidden = false + blockReasonView.update(title: reason.message, systemImage: "mappin.slash") + mapView.isHidden = true + } else { + blockReasonView.isHidden = true + mapView.isHidden = false + mapView.rings = viewModel.fenceRings + } + applyTableSnapshot() + } + + /// 通过 Diffable snapshot 刷新区域列表。 + private func applyTableSnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.items.map { String($0.id) }, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: animated) + } + + private func scopeText() -> String { + switch viewModel.mode { + case .storeAdmin: + return services.accountContext.currentStore?.name ?? "当前店铺" + case .scenicAdmin: + return services.accountContext.currentScenic?.name ?? "当前景区" + case nil: + return services.accountContext.currentScenic?.name + ?? services.accountContext.currentStore?.name + ?? "未选择业务范围" + } + } + + @objc private func onPullRefresh() { + Task { + await reload(showLoading: false) + tableView.refreshControl?.endRefreshing() + } + } + + private func reload(showLoading: Bool) async { + if showLoading { services.globalLoading.show() } + defer { if showLoading { services.globalLoading.hide() } } + await viewModel.reload( api: services.operatingAreaAPI, accountContext: services.accountContext, permissionContext: services.permissionContext ) + if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } } } diff --git a/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift index 2c6b8f8..a58325b 100644 --- a/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift +++ b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -110,6 +110,7 @@ final class OperatingAreaViewModel { mode?.title ?? "运营区域" } + /// apply 业务逻辑。 private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) { self.items = items if items.isEmpty { @@ -139,6 +140,7 @@ final class OperatingAreaViewModel { blockReason = parsed.isEmpty ? .noParsableFenceData : nil } + /// setBlocked相关逻辑。 private func setBlocked(_ reason: OperatingAreaBlockReason) { resetLoadedData() blockReason = reason @@ -146,6 +148,7 @@ final class OperatingAreaViewModel { loading = false } + /// 重置LoadedData状态。 private func resetLoadedData() { items = [] fenceRings = [] diff --git a/suixinkan_ios/Features/Orders/Models/OrderModels.swift b/suixinkan_ios/Features/Orders/Models/OrderModels.swift index 1d33233..4dfa2da 100644 --- a/suixinkan_ios/Features/Orders/Models/OrderModels.swift +++ b/suixinkan_ios/Features/Orders/Models/OrderModels.swift @@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { let isNeedEdit: Bool let isRefined: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case photogUid = "photog_uid" case orderNumber = "order_number" @@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable { let retouchGiftPhotoNum: Int let retouchGiftVideoNum: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderPhotoNum = "order_photo_num" case orderVideoNum = "order_video_num" @@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { let orderStatusName: String let orderVerificationTime: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case orderVerificationStatus = "order_verification_status" @@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { struct WriteOffRequest: Encodable { let orderNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" } @@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { let statusName: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case orderNumber = "order_number" @@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { struct DepositOrderWriteOffRequest: Encodable, Equatable { let orderNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" } @@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable { let orderNumber: String let refundReason: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case refundReason = "refund_reason" @@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable { let refundAmount: String let refundReason: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case refundType = "refund_type" @@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable { let remark: String let multiTravel: StoreOrderMultiTravelInfo? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case orderType = "order_type" @@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable { let projectInfo: StoreOrderProjectInfo? let shootingList: [StoreOrderShootingListItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case projectInfo = "project_info" case shootingList = "shooting_list" @@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable { let singleSpotPhotoNum: Int let singleSpotVideoNum: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case settleSpotNum = "settle_spot_num" case singleSpotMaterialNum = "single_spot_material_num" @@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable let startAvg: Double let start: Double + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case photogUid = "photog_uid" @@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable { let materialList: [OrderMediaFile] let completeList: [OrderMediaFile] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case scenicSpotName = "scenic_spot_name" @@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable { let content: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case starShooting = "star_shooting" case starRetouching = "star_retouching" @@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable { coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileUrl = "file_url" @@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable { let projectTypeName: String let photogSpotList: [PhotogSpotItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case projectName = "project_name" case projectType = "project_type" @@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable { return nickname.isEmpty ? photogName : nickname } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case photogUid = "photog_uid" @@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha let id: Int let name: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable { let cloudFile: [MultiTravelCloudFileItem] let uploadFile: [MultiTravelUploadFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case scenicSpotId = "scenic_spot_id" @@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable { struct MultiTravelCloudFileItem: Encodable, Equatable { let fileId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileId = "file_id" } @@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable { let fileName: String let fileUrl: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileUrl = "file_url" diff --git a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift index 6e30e7d..92ebbe6 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift @@ -6,32 +6,127 @@ import SnapKit import UIKit +// MARK: - Deposit List Diffable 标识 + +private typealias DepositListSection = Int +private typealias DepositListItem = String + +/// 押金列表空态 / 加载态标识。 +private enum DepositListItemID { + static let placeholder = "depositList:placeholder" +} + +// MARK: - Deposit Detail Diffable 标识 + +private typealias DepositDetailSection = Int +private typealias DepositDetailRow = String + +/// 押金详情行标识。 +private enum DepositDetailRowID { + static let placeholder = "depositDetail:placeholder" + static func field(_ index: Int) -> String { "depositDetail:field:\(index)" } +} + +// MARK: - Deposit Shooting Diffable 标识 + +private typealias DepositShootingSection = Int +private typealias DepositShootingRow = String + +/// 押金拍摄信息 section 索引。 +private enum DepositShootingSectionID { + static let summary = 0 + static let material = 1 + static let complete = 2 +} + +/// 押金拍摄信息行标识。 +private enum DepositShootingRowID { + static let placeholder = "depositShooting:placeholder" + static let scenicSpot = "depositShooting:scenicSpot" + static let rating = "depositShooting:rating" + static func material(_ index: Int) -> String { "depositShooting:material:\(index)" } + static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" } +} + /// 押金订单列表页,支持查询、分页、核销和退款。 -final class DepositOrderListViewController: UIViewController { +final class DepositOrderListViewController: UIViewController, UITableViewDelegate { private let viewModel = DepositOrderListViewModel() private var orderNumberField = UITextField() private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID) return table }() + /// Diffable 数据源,驱动押金订单列表与分页刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金订单" view.backgroundColor = AppDesignUIKit.pageBackground + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } setupHeader() Task { await reload(showLoading: true) } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: DepositListItem) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + if item == DepositListItemID.placeholder { + let cell = UITableViewCell() + cell.textLabel?.text = self.viewModel.loading ? "加载中..." : "暂无押金订单" + cell.selectionStyle = .none + return cell + } + guard let orderItem = self.viewModel.orders.first(where: { $0.orderNumber == item }) else { + return UITableViewCell() + } + let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell + cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber) + cell.onDetail = { [weak self] in + self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) } + } + cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) } + cell.onRefund = { [weak self] in self?.refund(orderItem) } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.orders.isEmpty { + snapshot.appendItems([DepositListItemID.placeholder], toSection: 0) + } else { + snapshot.appendItems(viewModel.orders.map(\.orderNumber), toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 初始化Header相关 UI 或状态。 private func setupHeader() { let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120)) orderNumberField.placeholder = "请输入押金订单号" @@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController { tableView.tableHeaderView = header } + /// open详情相关逻辑。 @objc private func openDetail() { let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !text.isEmpty else { return } HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self) } + /// 刷新。 private func reload(showLoading: Bool) async { if showLoading { await appServices.globalLoading.withLoading { @@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController { if let message = viewModel.errorMessage { showToast(message) } } + /// writeOff相关逻辑。 private func writeOff(_ item: DepositOrderListItem) { let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController { present(alert, animated: true) } + /// refund 业务逻辑。 private func refund(_ item: DepositOrderListItem) { let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert) alert.addTextField { $0.placeholder = "退款原因" } @@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController { }) present(alert, animated: true) } -} - -extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - max(viewModel.orders.count, 1) - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { "押金订单列表 \(viewModel.orders.count)/\(viewModel.total)" } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard !viewModel.orders.isEmpty else { - let cell = UITableViewCell() - cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单" - cell.selectionStyle = .none - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell - let item = viewModel.orders[indexPath.row] - cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber) - cell.onDetail = { [weak self] in - self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) } - } - cell.onWriteOff = { [weak self] in self?.writeOff(item) } - cell.onRefund = { [weak self] in self?.refund(item) } - return cell - } - + /// UITableView 代理:分页加载。 func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return } + guard let item = tableDataSource.itemIdentifier(for: indexPath), + item != DepositListItemID.placeholder, + item == viewModel.orders.last?.orderNumber, + viewModel.hasMore else { return } Task { await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false) } } } +/// DepositOrder列表或网格 Cell,负责单项内容展示。 private final class DepositOrderCell: UITableViewCell { static let reuseID = "DepositOrderCell" var onDetail: (() -> Void)? @@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell { private let statusLabel = UILabel() private let amountLabel = UILabel() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(item: DepositOrderListItem, isOperating: Bool) { titleLabel.text = item.orderNumber statusLabel.text = isOperating ? "处理中" : item.statusName amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)" } + /// 点击detail的处理逻辑。 @objc private func detailTapped() { onDetail?() } + /// 点击writeOff的处理逻辑。 @objc private func writeOffTapped() { onWriteOff?() } + /// 点击refund的处理逻辑。 @objc private func refundTapped() { onRefund?() } } @@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.backgroundColor = AppDesignUIKit.pageBackground return table }() + /// Diffable 数据源,驱动详情字段展示。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String) { self.orderNumber = orderNumber super.init(nibName: nil, bundle: nil) @@ -214,14 +303,66 @@ final class DepositOrderDetailViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金订单详情" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, _: IndexPath, row: DepositDetailRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + guard let detail = self.viewModel.detail else { + cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情") + return cell + } + let rows: [(String, String)] = [ + ("订单号", detail.orderNumber), + ("状态", detail.orderStatusName), + ("类型", detail.orderTypeLabel), + ("付款金额", detail.actualPayAmount), + ("手机号", detail.phone), + ("项目", detail.projectName), + ("创建时间", detail.createdAt), + ("备注", detail.remark) + ] + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + if index < rows.count { + cell.textLabel?.text = rows[index].0 + cell.detailTextLabel?.text = rows[index].1 + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.detail == nil { + snapshot.appendItems([DepositDetailRowID.placeholder], toSection: 0) + } else { + snapshot.appendItems((0..<8).map { DepositDetailRowID.field($0) }, toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading { await viewModel.load( @@ -230,41 +371,13 @@ final class DepositOrderDetailViewController: UIViewController { orderNumber: orderNumber ) } - tableView.reloadData() + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } } -extension DepositOrderDetailViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.detail == nil ? 1 : 8 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - guard let detail = viewModel.detail else { - cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情") - return cell - } - let rows: [(String, String)] = [ - ("订单号", detail.orderNumber), - ("状态", detail.orderStatusName), - ("类型", detail.orderTypeLabel), - ("付款金额", detail.actualPayAmount), - ("手机号", detail.phone), - ("项目", detail.projectName), - ("创建时间", detail.createdAt), - ("备注", detail.remark) - ] - cell.textLabel?.text = rows[indexPath.row].0 - cell.detailTextLabel?.text = rows[indexPath.row].1 - return cell - } -} - /// 押金拍摄信息页。 -final class DepositOrderShootingInfoViewController: UIViewController { +final class DepositOrderShootingInfoViewController: UIViewController, UITableViewDelegate { private let orderNumber: String private let scenicSpotId: Int @@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self + table.delegate = self return table }() + /// Diffable 数据源,驱动拍摄摘要与素材列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String, scenicSpotId: Int, photogUid: Int) { self.orderNumber = orderNumber self.scenicSpotId = scenicSpotId @@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金拍摄信息" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: DepositShootingRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + guard let detail = self.viewModel.detail else { + let cell = UITableViewCell() + cell.textLabel?.text = self.viewModel.errorMessage ?? "加载中..." + return cell + } + if row == DepositShootingRowID.scenicSpot || row == DepositShootingRowID.rating { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + if row == DepositShootingRowID.scenicSpot { + cell.textLabel?.text = "打卡点" + cell.detailTextLabel?.text = detail.scenicSpotName + } else { + cell.textLabel?.text = "拍摄评分" + cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--" + } + return cell + } + let files: [OrderMediaFile] + if row.hasPrefix("depositShooting:material:") { + files = detail.materialList + } else { + files = detail.completeList + } + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let media = files[index] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + cell.textLabel?.text = media.fileName + cell.detailTextLabel?.text = media.fileUrl + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + guard let detail = viewModel.detail else { + snapshot.appendSections([0]) + snapshot.appendItems([DepositShootingRowID.placeholder], toSection: 0) + return snapshot + } + snapshot.appendSections([DepositShootingSectionID.summary, DepositShootingSectionID.material, DepositShootingSectionID.complete]) + snapshot.appendItems([DepositShootingRowID.scenicSpot, DepositShootingRowID.rating], toSection: DepositShootingSectionID.summary) + snapshot.appendItems(detail.materialList.indices.map { DepositShootingRowID.material($0) }, toSection: DepositShootingSectionID.material) + snapshot.appendItems(detail.completeList.indices.map { DepositShootingRowID.complete($0) }, toSection: DepositShootingSectionID.complete) + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading { await viewModel.load( @@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController { photogUid: photogUid ) } - tableView.reloadData() + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } -} - -extension DepositOrderShootingInfoViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - viewModel.detail == nil ? 1 : 3 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let detail = viewModel.detail else { return 1 } - switch section { - case 0: return 2 - case 1: return detail.materialList.count - default: return detail.completeList.count - } - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - guard viewModel.detail != nil else { return nil } - switch section { - case 1: return "素材" - case 2: return "成片" + guard viewModel.detail != nil, + let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + switch sectionID { + case DepositShootingSectionID.material: return "素材" + case DepositShootingSectionID.complete: return "成片" default: return nil } } +} - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let detail = viewModel.detail else { - let cell = UITableViewCell() - cell.textLabel?.text = viewModel.errorMessage ?? "加载中..." - return cell - } - if indexPath.section == 0 { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - if indexPath.row == 0 { - cell.textLabel?.text = "打卡点" - cell.detailTextLabel?.text = detail.scenicSpotName - } else { - cell.textLabel?.text = "拍摄评分" - cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--" - } - return cell - } - let files = indexPath.section == 1 ? detail.materialList : detail.completeList - let media = files[indexPath.row] - let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) - cell.textLabel?.text = media.fileName - cell.detailTextLabel?.text = media.fileUrl - return cell +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift index b14438c..22794e6 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift @@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController { private let scannerController = OrderScannerCaptureViewController() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black @@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController { #endif } + /// embed扫码相关逻辑。 private func embedScanner() { scannerController.onScanResult = { [weak self] result in self?.onScanResult?(result) @@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController { } } + /// 展示UnavailablePlaceholder。 private func showUnavailablePlaceholder() { let stack = UIStackView() stack.axis = .vertical @@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController { } } + /// 点击close的处理逻辑。 @objc private func closeTapped() { dismiss(animated: true) } @@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada return button }() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black @@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada checkPermissionAndStart() } + /// 子视图布局完成后调整依赖 frame 的 UI。 override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds } + /// 视图即将消失,保存或清理临时状态。 override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) updateTorch(enabled: false) stopSession() } + /// check权限并启动相关逻辑。 private func checkPermissionAndStart() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: @@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// 启动Session流程。 private func startSession() { hasFinishedScan = false guard !session.isRunning else { return } @@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// 停止Session流程。 private func stopSession() { guard session.isRunning else { return } session.stopRunning() } + /// 创建ScannerButton实例。 private static func makeScannerButton(title: String) -> UIButton { var config = UIButton.Configuration.filled() config.title = title @@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada return UIButton(configuration: config) } + /// 配置Session展示内容。 private func configureSession() throws { if previewLayer != nil { return } guard let videoDevice = AVCaptureDevice.default(for: .video) else { @@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada torchButton.isEnabled = videoDevice.hasTorch } + /// metadataOutput相关逻辑。 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasFinishedScan else { return } guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, @@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada onScanResult?(.success(code)) } + /// 初始化Controls相关 UI 或状态。 private func setupControls() { view.addSubview(scanFrameView) view.addSubview(torchButton) @@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// toggleTorch相关逻辑。 @objc private func toggleTorch() { updateTorch(enabled: !(videoDevice?.isTorchActive ?? false)) } + /// 更新Torch状态。 private func updateTorch(enabled: Bool) { guard let device = videoDevice, device.hasTorch else { return } do { @@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// restart扫码相关逻辑。 @objc private func restartScan() { hasFinishedScan = false updateTorch(enabled: false) diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift index ca8dce9..c18c3ce 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift @@ -6,8 +6,45 @@ import SnapKit import UIKit +// MARK: - Store Order Diffable 标识 + +private typealias StoreOrderDetailSection = Int +private typealias StoreOrderDetailRow = String + +/// 门店订单详情 section 索引。 +private enum StoreOrderDetailSectionID { + static let context = 0 + static let orderInfo = 1 + static let payment = 2 + static let customer = 3 + static let shooting = 4 + static let actions = 5 +} + +/// 门店订单详情行标识。 +private enum StoreOrderDetailRowID { + static let context = "storeDetail:context" + static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" } + static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" } + static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" } + static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" } + static func action(_ index: Int) -> String { "storeDetail:action:\(index)" } +} + +// MARK: - Write-Off Diffable 标识 + +/// 核销详情行标识。 +private enum WriteOffDetailRowID { + static let orderNumber = "writeOff:orderNumber" + static let project = "writeOff:project" + static let phone = "writeOff:phone" + static let amount = "writeOff:amount" + static let status = "writeOff:status" + static let verificationTime = "writeOff:verificationTime" +} + /// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 -final class StoreOrderDetailViewController: UIViewController { +final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate { private let item: OrderEntity private let viewModel: OrderDetailViewModel @@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self table.backgroundColor = AppDesignUIKit.pageBackground return table }() + /// Diffable 数据源,驱动多 section 订单详情刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(item: OrderEntity) { self.item = item self.viewModel = OrderDetailViewModel(item: item) @@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "订单详情" view.backgroundColor = AppDesignUIKit.pageBackground + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: StoreOrderDetailRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + return self.configureDetailCell(row: row) + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + + if viewModel.contextMessage != nil { + snapshot.appendSections([StoreOrderDetailSectionID.context]) + snapshot.appendItems([StoreOrderDetailRowID.context], toSection: StoreOrderDetailSectionID.context) + } + + snapshot.appendSections([StoreOrderDetailSectionID.orderInfo]) + snapshot.appendItems((0..<7).map { StoreOrderDetailRowID.orderInfo($0) }, toSection: StoreOrderDetailSectionID.orderInfo) + + snapshot.appendSections([StoreOrderDetailSectionID.payment]) + snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.payment($0) }, toSection: StoreOrderDetailSectionID.payment) + + snapshot.appendSections([StoreOrderDetailSectionID.customer]) + snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.customer($0) }, toSection: StoreOrderDetailSectionID.customer) + + if !viewModel.shootingList.isEmpty { + snapshot.appendSections([StoreOrderDetailSectionID.shooting]) + let shootingRows = viewModel.shootingList.enumerated().map { index, shooting in + StoreOrderDetailRowID.shooting("\(shooting.id)-\(index)") + } + snapshot.appendItems(shootingRows, toSection: StoreOrderDetailSectionID.shooting) + } + + snapshot.appendSections([StoreOrderDetailSectionID.actions]) + snapshot.appendItems((0..<5).map { StoreOrderDetailRowID.action($0) }, toSection: StoreOrderDetailSectionID.actions) + + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 配置详情 Cell 内容。 + private func configureDetailCell(row: StoreOrderDetailRow) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.textLabel?.numberOfLines = 1 + cell.textLabel?.textColor = nil + cell.accessoryType = .none + cell.isUserInteractionEnabled = true + let display = viewModel.display + + if row == StoreOrderDetailRowID.context { + cell.textLabel?.text = viewModel.contextMessage + cell.textLabel?.numberOfLines = 0 + return cell + } + + if row.hasPrefix("storeDetail:orderInfo:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = display.orderNumber + case 1: cell.detailTextLabel?.text = display.orderStatusName + case 2: cell.detailTextLabel?.text = display.orderTypeLabel + case 3: cell.detailTextLabel?.text = display.createdAt + case 4: cell.detailTextLabel?.text = display.payTime + case 5: cell.detailTextLabel?.text = display.completeTime + default: + cell.textLabel?.textColor = AppDesignUIKit.primary + cell.selectionStyle = .default + } + return cell + } + + if row.hasPrefix("storeDetail:payment:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))" + case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))" + case 2: cell.detailTextLabel?.text = display.payTypeName + default: cell.detailTextLabel?.text = "\(display.userId)" + } + return cell + } + + if row.hasPrefix("storeDetail:customer:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["手机号", "关联项目", "项目 ID", "备注"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = display.phone + case 1: cell.detailTextLabel?.text = display.projectName + case 2: cell.detailTextLabel?.text = "\(display.projectId)" + default: cell.detailTextLabel?.text = display.remark + } + return cell + } + + if row.hasPrefix("storeDetail:shooting:") { + let suffix = row.replacingOccurrences(of: "storeDetail:shooting:", with: "") + let indexPart = suffix.split(separator: "-").last.flatMap { Int($0) } ?? 0 + if indexPart < viewModel.shootingList.count { + let shooting = viewModel.shootingList[indexPart] + cell.textLabel?.text = shooting.scenicSpotName + cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName + } + return cell + } + + if row.hasPrefix("storeDetail:action:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"] + cell.textLabel?.text = actions[index] + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + if index == 4, !refundViewModel.canRefund(item) { + cell.isUserInteractionEnabled = false + cell.textLabel?.textColor = AppDesignUIKit.textSecondary + } + return cell + } + + return cell + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading(message: "加载详情中...") { await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id) } + applyTableSnapshot(reconfigure: true) if let error = viewModel.errorMessage { showToast(error) } } + /// 复制订单Number。 private func copyOrderNumber() { UIPasteboard.general.string = viewModel.display.orderNumber showToast("订单号已复制") } + /// 弹出Refund页面。 private func presentRefund() { refundViewModel.begin(item: item) let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert) @@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController { }) present(alert, animated: true) } -} - -extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 6 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: return viewModel.contextMessage == nil ? 0 : 1 - case 1: return 7 - case 2: return 4 - case 3: return 4 - case 4: return viewModel.shootingList.count - case 5: return 5 - default: return 0 - } - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case 1: "订单信息" - case 2: "支付信息" - case 3: "客户与项目" - case 4 where !viewModel.shootingList.isEmpty: "拍摄点" - case 5: "后续功能" - default: nil + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + switch sectionID { + case StoreOrderDetailSectionID.orderInfo: return "订单信息" + case StoreOrderDetailSectionID.payment: return "支付信息" + case StoreOrderDetailSectionID.customer: return "客户与项目" + case StoreOrderDetailSectionID.shooting: return "拍摄点" + case StoreOrderDetailSectionID.actions: return "后续功能" + default: return nil } } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - let display = viewModel.display - - switch indexPath.section { - case 0: - cell.textLabel?.text = viewModel.contextMessage - cell.textLabel?.numberOfLines = 0 - case 1: - let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = display.orderNumber - case 1: cell.detailTextLabel?.text = display.orderStatusName - case 2: cell.detailTextLabel?.text = display.orderTypeLabel - case 3: cell.detailTextLabel?.text = display.createdAt - case 4: cell.detailTextLabel?.text = displayPayTime - case 5: cell.detailTextLabel?.text = display.completeTime - default: - cell.textLabel?.textColor = AppDesignUIKit.primary - cell.selectionStyle = .default - } - case 2: - let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))" - case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))" - case 2: cell.detailTextLabel?.text = display.payTypeName - default: cell.detailTextLabel?.text = "\(display.userId)" - } - case 3: - let rows = ["手机号", "关联项目", "项目 ID", "备注"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = display.phone - case 1: cell.detailTextLabel?.text = display.projectName - case 2: cell.detailTextLabel?.text = "\(display.projectId)" - default: cell.detailTextLabel?.text = display.remark - } - case 4: - let shooting = viewModel.shootingList[indexPath.row] - cell.textLabel?.text = shooting.scenicSpotName - cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName - case 5: - let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"] - cell.textLabel?.text = actions[indexPath.row] - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - if indexPath.row == 4, !refundViewModel.canRefund(item) { - cell.isUserInteractionEnabled = false - cell.textLabel?.textColor = AppDesignUIKit.textSecondary - } - default: break - } - return cell - } - + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return } - guard indexPath.section == 5 else { return } + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + + if row == StoreOrderDetailRowID.orderInfo(6) { + copyOrderNumber() + return + } + + guard row.hasPrefix("storeDetail:action:") else { return } + let index = Int(row.split(separator: ":").last ?? "") ?? -1 let orderNumber = viewModel.display.orderNumber - switch indexPath.row { + switch index { case 0: HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self) case 1 where item.orderType == 19: @@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self) case 4: presentRefund() - default: break + default: + break } } @@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele return payTime } + /// empty至Zero相关逻辑。 private func emptyToZero(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value } } +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + /// 核销订单详情页,展示核销列表项摘要信息。 -final class WriteOffOrderDetailViewController: UIViewController { +final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController { private let item: WriteOffOrderItem - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self - table.backgroundColor = AppDesignUIKit.pageBackground - return table - }() - + /// 初始化实例。 init(item: WriteOffOrderItem) { self.item = item - super.init(nibName: nil, bundle: nil) + super.init(style: .insetGrouped) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "核销详情" - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + tableView.backgroundColor = AppDesignUIKit.pageBackground } -} -extension WriteOffOrderDetailViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 } + /// 构建 Diffable snapshot。 + override func buildSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems([ + WriteOffDetailRowID.orderNumber, + WriteOffDetailRowID.project, + WriteOffDetailRowID.phone, + WriteOffDetailRowID.amount, + WriteOffDetailRowID.status, + WriteOffDetailRowID.verificationTime + ], toSection: 0) + return snapshot + } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + /// 配置 Cell 展示内容。 + override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) { cell.selectionStyle = .none - switch indexPath.row { - case 0: + switch row { + case WriteOffDetailRowID.orderNumber: cell.textLabel?.text = "订单号" cell.detailTextLabel?.text = item.orderNumber - case 1: + case WriteOffDetailRowID.project: cell.textLabel?.text = "项目" cell.detailTextLabel?.text = item.projectName - case 2: + case WriteOffDetailRowID.phone: cell.textLabel?.text = "手机号" cell.detailTextLabel?.text = item.userPhone - case 3: + case WriteOffDetailRowID.amount: cell.textLabel?.text = "金额" cell.detailTextLabel?.text = "¥\(item.orderAmount)" - case 4: + case WriteOffDetailRowID.status: cell.textLabel?.text = "状态" cell.detailTextLabel?.text = item.orderStatusName - default: + case WriteOffDetailRowID.verificationTime: cell.textLabel?.text = "核销时间" cell.detailTextLabel?.text = item.orderVerificationTime + default: + break } - return cell } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift index 40e7157..44011cc 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift @@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { private let localListLabel = UILabel() private let submitButton = UIButton(type: .system) + /// 初始化实例。 init(initialOrderNumber: String) { self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber) super.init(nibName: nil, bundle: nil) @@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "任务上传" @@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController { Task { await viewModel.loadSpots(api: appServices.ordersAPI) } } + /// 绑定ViewModel回调或数据。 private func bindViewModel() { viewModel.onChange = { [weak self] in self?.applyViewModel() } } + /// 初始化Form相关 UI 或状态。 private func setupForm() { orderField.borderStyle = .roundedRect orderField.placeholder = "关联订单号" @@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { applyViewModel() } + /// labeled行相关逻辑。 private func labeledRow(_ title: String, _ content: UIView) -> UIStackView { let label = UILabel() label.text = title @@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { return row } + /// apply视图模型相关逻辑。 private func applyViewModel() { orderField.text = viewModel.orderNumber spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal) @@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController { submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材" } + /// order变更相关逻辑。 @objc private func orderChanged() { viewModel.orderNumber = orderField.text ?? "" } + /// 刷新Spots展示。 @objc private func refreshSpots() { Task { await viewModel.loadSpots(api: appServices.ordersAPI) } } + /// select打卡点相关逻辑。 @objc private func selectSpot() { guard !viewModel.spots.isEmpty else { return } let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet) @@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { present(sheet, animated: true) } + /// pick本地Files相关逻辑。 @objc private func pickLocalFiles() { var config = PHPickerConfiguration() config.selectionLimit = 9 @@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { present(picker, animated: true) } + /// 提交Tapped。 @objc private func submitTapped() { Task { let success = await viewModel.submit( @@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { } extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } @@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { } } +// MARK: - Historical Shooting Diffable 标识 + +private typealias HistoricalShootingSection = Int +private typealias HistoricalShootingRow = String + +/// 历史拍摄 section 索引。 +private enum HistoricalShootingSectionID { + static let summary = 0 + static let spots = 1 +} + +/// 历史拍摄行标识。 +private enum HistoricalShootingRowID { + static let project = "historicalShooting:project" + static let projectType = "historicalShooting:projectType" + static let empty = "historicalShooting:empty" + static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" } +} + /// 历史拍摄信息页。 -final class HistoricalShootingInfoViewController: UIViewController { +final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate { private let orderNumber: String private let viewModel = HistoricalShootingInfoViewModel() private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self return table }() + /// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String) { self.orderNumber = orderNumber super.init(nibName: nil, bundle: nil) @@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "历史拍摄" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } Task { await loadData() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, _: IndexPath, row: HistoricalShootingRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + switch row { + case HistoricalShootingRowID.project: + cell.textLabel?.text = "项目" + cell.detailTextLabel?.text = self.viewModel.projectName + case HistoricalShootingRowID.projectType: + cell.textLabel?.text = "项目类型" + cell.detailTextLabel?.text = self.viewModel.projectTypeName + case HistoricalShootingRowID.empty: + cell.textLabel?.text = self.viewModel.errorMessage ?? "暂无历史拍摄" + default: + if row.hasPrefix("historicalShooting:spot:") { + let id = row.replacingOccurrences(of: "historicalShooting:spot:", with: "") + if let spot = self.viewModel.spots.first(where: { $0.id == id }) { + cell.textLabel?.text = spot.scenicSpotName + cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)" + } + } + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([HistoricalShootingSectionID.summary, HistoricalShootingSectionID.spots]) + snapshot.appendItems([HistoricalShootingRowID.project, HistoricalShootingRowID.projectType], toSection: HistoricalShootingSectionID.summary) + if viewModel.spots.isEmpty { + snapshot.appendItems([HistoricalShootingRowID.empty], toSection: HistoricalShootingSectionID.spots) + } else { + snapshot.appendItems(viewModel.spots.map { HistoricalShootingRowID.spot($0.id) }, toSection: HistoricalShootingSectionID.spots) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 加载Data数据。 private func loadData() async { await appServices.globalLoading.withLoading { await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber) } + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } -} - -extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 2 : max(viewModel.spots.count, 1) - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 1 ? "拍摄点位" : nil - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - if indexPath.section == 0 { - cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型" - cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName - return cell - } - guard !viewModel.spots.isEmpty else { - cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄" - return cell - } - let spot = viewModel.spots[indexPath.row] - cell.textLabel?.text = spot.scenicSpotName - cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)" - return cell + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift index 36a7059..b7de248 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift @@ -6,6 +6,31 @@ import SnapKit import UIKit +// MARK: - Diffable 标识(Int / String 避免 MainActor 默认隔离下 Hashable 冲突) + +/// 订单列表 section 索引。 +private enum OrdersSectionID { + static let header = 0 + static let toolbar = 1 + static let content = 2 +} + +private typealias OrdersSection = Int + +/// 订单列表 item 字符串标识。 +private enum OrdersItemID { + static let header = "orders:header" + static let filter = "orders:filter" + static let writeOffAction = "orders:writeOffAction" + static let missingContext = "orders:missingContext" + static let emptyStore = "orders:empty:store" + static let emptyWriteOff = "orders:empty:writeOff" + static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" } + static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" } +} + +private typealias OrdersItem = String + /// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。 final class OrdersViewController: UIViewController { @@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController { private var manualOrderNumber = "" private var scanHintMessage: String? - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.dataSource = self - table.delegate = self - table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID) - table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID) - table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID) - table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID) - table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID) + collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID) + collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID) + collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID) + collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID) + collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID) + return collection + }() + + private lazy var dataSource: UICollectionViewDiffableDataSource = { + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in + self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell() + } }() private lazy var refreshControl = UIRefreshControl() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "订单" view.backgroundColor = AppDesignUIKit.pageBackground - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) - tableView.refreshControl = refreshControl + collectionView.refreshControl = refreshControl viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applySnapshot() } appServices.appRouter.onChange = { [weak self] in @@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController { } } + /// 构建 Compositional Layout,按 section 类型选用全宽布局。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self else { + return CollectionDiffableLayout.fullWidthSection() + } + let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] + switch section { + case OrdersSectionID.header: + return CollectionDiffableLayout.fullWidthSection(height: 120) + case OrdersSectionID.toolbar: + let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150 + return CollectionDiffableLayout.fullWidthSection(height: height) + case OrdersSectionID.content: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88) + default: + return CollectionDiffableLayout.fullWidthSection() + } + } + } + + /// 根据当前 ViewModel 与页面状态构建并应用 Diffable 快照。 + private func applySnapshot(animatingDifferences: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([OrdersSectionID.header]) + snapshot.appendItems([OrdersItemID.header], toSection: OrdersSectionID.header) + + guard currentScenicId != nil else { + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + return + } + + snapshot.appendSections([OrdersSectionID.toolbar, OrdersSectionID.content]) + + if viewModel.selectedEntry == .storeOrders { + snapshot.appendItems([OrdersItemID.filter], toSection: OrdersSectionID.toolbar) + appendStoreOrderItems(to: &snapshot) + } else { + snapshot.appendItems([OrdersItemID.writeOffAction], toSection: OrdersSectionID.toolbar) + appendWriteOffOrderItems(to: &snapshot) + } + + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + /// 向快照追加订单管理列表或空态 item。 + private func appendStoreOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot) { + if viewModel.storeOrders.isEmpty { + guard !(viewModel.loading && viewModel.storeOrders.isEmpty) else { return } + snapshot.appendItems([OrdersItemID.emptyStore], toSection: OrdersSectionID.content) + return + } + snapshot.appendItems( + viewModel.storeOrders.map { OrdersItemID.storeOrder($0.orderNumber) }, + toSection: OrdersSectionID.content + ) + } + + /// 向快照追加核销订单列表或空态 item。 + private func appendWriteOffOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot) { + if viewModel.writeOffOrders.isEmpty { + guard !(viewModel.loading && viewModel.writeOffOrders.isEmpty) else { return } + snapshot.appendItems([OrdersItemID.emptyWriteOff], toSection: OrdersSectionID.content) + return + } + snapshot.appendItems( + viewModel.writeOffOrders.map { OrdersItemID.writeOffOrder($0.orderNumber) }, + toSection: OrdersSectionID.content + ) + } + + /// 按 item 类型 dequeue 并配置对应 Cell。 + private func cell( + for collectionView: UICollectionView, + at indexPath: IndexPath, + item: OrdersItem + ) -> UICollectionViewCell { + switch item { + case OrdersItemID.header: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersHeaderCell.reuseID, + for: indexPath + ) as! OrdersHeaderCell + cell.configure( + selectedEntry: viewModel.selectedEntry, + scenicName: appServices.accountContext.currentScenic?.name ?? "--", + storeTotal: viewModel.storeTotal, + writeOffTotal: viewModel.writeOffTotal, + onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } + ) + return cell + + case OrdersItemID.filter: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersFilterCell.reuseID, + for: indexPath + ) as! OrdersFilterCell + let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" + cell.configure( + statusTitle: statusTitle, + phone: viewModel.searchPhone, + onStatus: { [weak self] in self?.presentStatusFilter() }, + onDate: { [weak self] in self?.presentDateFilter() }, + onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, + onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } + ) + return cell + + case OrdersItemID.writeOffAction: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersWriteOffActionCell.reuseID, + for: indexPath + ) as! OrdersWriteOffActionCell + cell.configure( + manualOrderNumber: manualOrderNumber, + isVerifying: viewModel.isVerifying, + hint: scanHintMessage, + onScan: { [weak self] in self?.presentScanner() }, + onManualChange: { [weak self] text in self?.manualOrderNumber = text }, + onVerify: { [weak self] in + guard let self, !self.manualOrderNumber.isEmpty else { return } + self.confirmVerify(orderNumber: self.manualOrderNumber) + } + ) + return cell + + case OrdersItemID.missingContext: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView( + title: "缺少经营上下文", + message: "请先在首页选择景区后查看订单。", + systemImage: "mountain.2" + ), + preferredHeight: 360 + ) + return cell + + case OrdersItemID.emptyStore: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray"), + preferredHeight: 260 + ) + return cell + + case OrdersItemID.emptyWriteOff: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray"), + preferredHeight: 260 + ) + return cell + + default: + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { + return UICollectionViewCell() + } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrderEntityCell.reuseID, + for: indexPath + ) as! OrderEntityCell + cell.configure(item: order) + return cell + } + if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { + return UICollectionViewCell() + } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: WriteOffOrderCell.reuseID, + for: indexPath + ) as! WriteOffOrderCell + cell.configure( + item: order, + isVerifying: viewModel.currentVerifyingOrderNumber == order.orderNumber + ) + return cell + } + return UICollectionViewCell() + } + } + + /// 下拉刷新触发重新加载。 @objc private func refreshPulled() { Task { await reload(showLoading: false) @@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController { private var currentStoreId: Int? { appServices.accountContext.currentStore?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 重新加载当前子入口对应的订单数据。 private func reload(showLoading: Bool) async { do { try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") { @@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController { } } + /// 滚动到底部时加载下一页。 private func loadMore() async { do { if viewModel.selectedEntry == .storeOrders { @@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController { } } + /// 切换订单管理 / 核销订单子入口。 private func switchEntry(_ entry: OrdersEntry) { guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry @@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController { Task { await reload(showLoading: true) } } + /// 提交订单核销请求。 private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { @@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController { } } + /// 消费路由中暂存的扫码结果(核销入口)。 private func consumePendingScanCodeIfNeeded() async { guard viewModel.selectedEntry == .verificationOrders, let code = appServices.appRouter.consumePendingOrderScanCode() else { return } handleScanResult(code) } + /// 解析扫码结果并引导核销或提示未匹配。 private func handleScanResult(_ rawCode: String) { guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else { showToast("未识别到有效订单号") @@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController { confirmVerify(orderNumber: parsed.orderNumber) } else { scanHintMessage = "扫码成功,当前列表未找到该订单" - tableView.reloadData() + applySnapshot() } } + /// 弹出核销确认对话框。 private func confirmVerify(orderNumber: String) { let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController { present(alert, animated: true) } + /// 打开订单号扫码页。 private func presentScanner() { let scanner = OrderCodeScannerViewController() scanner.onScanResult = { [weak self, weak scanner] result in @@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController { present(nav, animated: true) } + /// 弹出时间筛选快捷选项。 private func presentDateFilter() { let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in @@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController { present(alert, animated: true) } + /// 弹出订单状态筛选 ActionSheet。 private func presentStatusFilter() { let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet) for filter in OrderFilters.statusFilters { @@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController { } } -extension OrdersViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - guard currentScenicId != nil else { return 1 } - return viewModel.selectedEntry == .storeOrders ? 3 : 3 - } +// MARK: - UICollectionViewDelegate - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == 0 { return 1 } - if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 } - if currentScenicId == nil { return 1 } - if viewModel.selectedEntry == .storeOrders { - return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1) - } - return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == 0 { - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell - let services = appServices - cell.configure( - selectedEntry: viewModel.selectedEntry, - scenicName: services.accountContext.currentScenic?.name ?? "--", - storeTotal: viewModel.storeTotal, - writeOffTotal: viewModel.writeOffTotal, - onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } - ) - return cell - } - - if currentScenicId == nil { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in - make.edges.equalToSuperview() - make.height.equalTo(360) - } - return cell - } - - if indexPath.section == 1 { - if viewModel.selectedEntry == .storeOrders { - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell - let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" - cell.configure( - statusTitle: statusTitle, - phone: viewModel.searchPhone, - onStatus: { [weak self] in self?.presentStatusFilter() }, - onDate: { [weak self] in self?.presentDateFilter() }, - onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, - onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } - ) - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell - cell.configure( - manualOrderNumber: manualOrderNumber, - isVerifying: viewModel.isVerifying, - hint: scanHintMessage, - onScan: { [weak self] in self?.presentScanner() }, - onManualChange: { [weak self] text in self?.manualOrderNumber = text }, - onVerify: { [weak self] in - guard let self, !self.manualOrderNumber.isEmpty else { return } - self.confirmVerify(orderNumber: self.manualOrderNumber) - } - ) - return cell - } - - if viewModel.selectedEntry == .storeOrders { - if viewModel.storeOrders.isEmpty { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } - return cell - } - let item = viewModel.storeOrders[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell - cell.configure(item: item) - return cell - } - - if viewModel.writeOffOrders.isEmpty { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } - return cell - } - let item = viewModel.writeOffOrders[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell - cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber) - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.section == 2 else { return } - if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count { - HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self) - } else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count { - HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self) +extension OrdersViewController: UICollectionViewDelegate { + /// 点击订单卡片进入详情。 + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return } + HomeMenuRouting.pushOrders(.storeDetail(order), from: self) + } else if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return } + HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self) } } - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.section == 2 else { return } + /// 列表即将展示最后一项时触发分页加载。 + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } let isLast: Bool - if viewModel.selectedEntry == .storeOrders { - isLast = indexPath.row == viewModel.storeOrders.count - 1 + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + isLast = orderNumber == viewModel.storeOrders.last?.orderNumber + } else if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber } else { - isLast = indexPath.row == viewModel.writeOffOrders.count - 1 + isLast = false + } + if isLast { + Task { await loadMore() } } - if isLast { Task { await loadMore() } } - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == 0 { return 120 } - if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 } - return UITableView.automaticDimension } } // MARK: - Cells -private final class OrdersHeaderCell: UITableViewCell { +/// 订单页头部 Cell,展示子入口切换与统计摘要。 +private final class OrdersHeaderCell: UICollectionViewCell { static let reuseID = "OrdersHeaderCell" private var onSelectEntry: ((OrdersEntry) -> Void)? @@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell { private let leftPill = UILabel() private let rightPill = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) } let segmentBackground = UIView() @@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) { + /// 配置头部展示内容与入口切换回调。 + func configure( + selectedEntry: OrdersEntry, + scenicName: String, + storeTotal: Int, + writeOffTotal: Int, + onSelectEntry: @escaping (OrdersEntry) -> Void + ) { self.onSelectEntry = onSelectEntry updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders) updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders) @@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell { } } + /// 更新分段按钮选中样式。 private func updateSegment(_ button: UIButton, title: String, selected: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium) @@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell { button.layer.cornerRadius = 6 } + /// 切换到订单管理入口。 @objc private func storeTapped() { onSelectEntry?(.storeOrders) } + /// 切换到核销订单入口。 @objc private func verifyTapped() { onSelectEntry?(.verificationOrders) } } -private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { +/// 订单筛选栏 Cell,承载状态 / 时间与手机号搜索。 +private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate { static let reuseID = "OrdersFilterCell" private let phoneField = UITextField() private var onPhoneChange: ((String) -> Void)? private var onSearch: (() -> Void)? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) } phoneField.delegate = self phoneField.keyboardType = .phonePad @@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) { + /// 配置筛选栏展示内容与交互回调。 + func configure( + statusTitle: String, + phone: String, + onStatus: @escaping () -> Void, + onDate: @escaping () -> Void, + onPhoneChange: @escaping (String) -> Void, + onSearch: @escaping () -> Void + ) { phoneField.text = phone self.onPhoneChange = onPhoneChange self.onSearch = onSearch } + /// 手机号输入变化时同步到 ViewModel。 func textFieldDidChangeSelection(_ textField: UITextField) { onPhoneChange?(textField.text ?? "") } } -private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate { +/// 核销操作栏 Cell,承载扫码与手动输入核销。 +private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate { static let reuseID = "OrdersWriteOffActionCell" private let manualField = UITextField() private var onManualChange: ((String) -> Void)? private var onVerify: (() -> Void)? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } manualField.delegate = self manualField.placeholder = "手动输入订单号" @@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) { + /// 配置核销操作栏展示内容与交互回调。 + func configure( + manualOrderNumber: String, + isVerifying: Bool, + hint: String?, + onScan: @escaping () -> Void, + onManualChange: @escaping (String) -> Void, + onVerify: @escaping () -> Void + ) { manualField.text = manualOrderNumber self.onManualChange = onManualChange self.onVerify = onVerify } + /// 手动订单号输入变化时同步到页面状态。 func textFieldDidChangeSelection(_ textField: UITextField) { onManualChange?(textField.text ?? "") } } -private final class OrderEntityCell: UITableViewCell { +/// 订单管理列表 Cell,展示单笔订单摘要。 +private final class OrderEntityCell: UICollectionViewCell { static let reuseID = "OrderEntityCell" private let titleLabel = UILabel() private let statusLabel = UILabel() private let amountLabel = UILabel() private let phoneLabel = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .default + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) @@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置订单卡片展示内容。 func configure(item: OrderEntity) { titleLabel.text = item.orderNumber statusLabel.text = item.orderStatusName @@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell { } } -private final class WriteOffOrderCell: UITableViewCell { +/// 核销订单列表 Cell,展示单笔核销订单摘要。 +private final class WriteOffOrderCell: UICollectionViewCell { static let reuseID = "WriteOffOrderCell" private let titleLabel = UILabel() private let subtitleLabel = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) subtitleLabel.textColor = AppDesignUIKit.textSecondary @@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置核销订单卡片展示内容。 func configure(item: WriteOffOrderItem, isVerifying: Bool) { titleLabel.text = item.orderNumber subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName) } } + +/// 订单列表空态 Cell,复用通用空态视图。 +private final class OrdersEmptyStateCell: UICollectionViewCell { + static let reuseID = "OrdersEmptyStateCell" + private var heightConstraint: Constraint? + + /// 初始化 Cell。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// 嵌入空态视图并设置占位高度。 + func embed(_ emptyView: UIView, preferredHeight: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + contentView.addSubview(emptyView) + emptyView.snp.makeConstraints { make in + make.edges.equalToSuperview() + heightConstraint = make.height.equalTo(preferredHeight).constraint + } + } +} diff --git a/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift index 47f337e..8f6b19e 100644 --- a/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift +++ b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift @@ -120,6 +120,7 @@ final class DepositOrderListViewModel { errorMessage = nil } + /// 重置State状态。 private func resetState() { orders = [] total = 0 @@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { detail = nil loading = false @@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { detail = nil loading = false @@ -324,10 +327,12 @@ final class OrderRefundViewModel { return moneyText(value) } + /// decimal值相关逻辑。 private static func decimalValue(_ text: String) -> Decimal { Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 } + /// money文本相关逻辑。 private static func moneyText(_ value: Decimal) -> String { let number = NSDecimalNumber(decimal: value) let handler = NSDecimalNumberHandler( @@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { projectName = "" projectTypeName = "" @@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel { errorMessage = nil } + /// 校验BeforeSubmit输入或状态。 private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard let scenicId, scenicId > 0 else { @@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel { return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) } + /// upload本地Files相关逻辑。 private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { var uploadFiles: [MultiTravelUploadFileItem] = [] for index in selectedLocalFiles.indices { @@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel { return uploadFiles } + /// 更新LocalFileProgress状态。 private func updateLocalFileProgress(id: UUID, progress: Int) { guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } selectedLocalFiles[index].progress = progress } + /// 重置SpotSelection状态。 private func resetSpotSelection() { spots = [] selectedSpotId = nil @@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel { isLoadingSpots = false } + /// fileType相关逻辑。 private static func fileType(for fileName: String) -> Int { let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 } + /// Submit上下文,持有跨页面共享状态。 private struct SubmitContext { let scenicId: Int let orderNumber: String diff --git a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift index ca4e9a2..0e50279 100644 --- a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift +++ b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift @@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController { private let remarkField = UITextField() private let activityIndicator = UIActivityIndicatorView(style: .large) + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "收款" @@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController { Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) } } + /// 初始化UI相关 UI 或状态。 private func setupUI() { qrImageView.contentMode = .scaleAspectFit statusLabel.numberOfLines = 0 @@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController { ] } + /// render 业务逻辑。 private func render() { qrImageView.image = viewModel.qrImage statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status) if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } } + /// apply金额相关逻辑。 @objc private func applyAmount() { viewModel.amountText = amountField.text ?? "" viewModel.remarkText = remarkField.text ?? "" _ = viewModel.applyDynamicAmount() } + /// togglePolling相关逻辑。 @objc private func togglePolling() { Task { await viewModel.pollUntilPaymentDetected( diff --git a/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift index e821186..abac7cf 100644 --- a/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift +++ b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift @@ -10,9 +10,13 @@ import Foundation /// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。 @MainActor protocol PilotCertificationServing { + /// 飞手详情相关逻辑。 func flyerDetail() async throws -> FlyerDetailResponse + /// 飞手Send验证码相关逻辑。 func flyerSendCode(phone: String) async throws + /// 飞手提交相关逻辑。 func flyerApply(_ request: FlyerApplyRequest) async throws + /// 飞手编辑相关逻辑。 func flyerEdit(_ request: FlyerEditRequest) async throws } diff --git a/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift index f7ae4ca..749db49 100644 --- a/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift +++ b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift @@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable { let realnameStatusText: String let certificationLogs: [FlyerCertificationLogItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name = "flyer_nickname" @@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable { case certificationLogs = "flyers_certification_logs" } + /// 初始化实例。 init( id: Int = 0, name: String = "", @@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable { self.certificationLogs = certificationLogs } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 @@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { let remark: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case flyerId = "flyer_id" @@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { case createdAt = "created_at" } + /// 初始化实例。 init( id: Int = 0, flyerId: Int = 0, @@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { self.createdAt = createdAt } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 @@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable { let contactPhone: String let code: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case name case realnameStatus = "realname_status" @@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable { let contactPhone: String let code: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable { } private extension KeyedDecodingContainer { + /// pilot解码宽松字符串相关逻辑。 func pilotDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -293,6 +302,7 @@ private extension KeyedDecodingContainer { return "" } + /// pilot解码宽松整数相关逻辑。 func pilotDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift index 45a9409..9537bf1 100644 --- a/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift +++ b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift @@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController { private let viewModel = PilotCertificationViewModel() private let statusLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "飞手认证" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateHeader() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { statusLabel.numberOfLines = 0 statusLabel.font = .systemFont(ofSize: 14) @@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController { tableView.tableHeaderView = statusLabel } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? formRows.count : 1 } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "认证信息" : "操作" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 1 { Task { do { @@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI) updateHeader() } + /// 提交。 @objc private func submit() { Task { do { @@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController { ] } + /// 更新Header状态。 private func updateHeader() { let status = viewModel.auditStatusText statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")" diff --git a/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift index dcdc2a0..8236e97 100644 --- a/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift +++ b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift @@ -10,6 +10,7 @@ import Foundation @MainActor /// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。 protocol PilotRealNameServing { + /// realNameInfo相关逻辑。 func realNameInfo() async throws -> RealNameInfoResponse } @@ -167,6 +168,7 @@ final class PilotCertificationViewModel { flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 } } + /// apply 业务逻辑。 private func apply(_ flyer: FlyerDetailResponse) { self.flyer = flyer name = flyer.name @@ -183,6 +185,7 @@ final class PilotCertificationViewModel { uploadProgress = nil } + /// upload待处理Certificate图片相关逻辑。 private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws { guard let data = pendingCertificateImageData else { return } guard let scenicId, scenicId > 0 else { @@ -203,6 +206,7 @@ final class PilotCertificationViewModel { pendingCertificateFileName = nil } + /// 创建ApplyRequest实例。 private func makeApplyRequest() -> FlyerApplyRequest { FlyerApplyRequest( name: name.trimmedForPilot, @@ -219,6 +223,7 @@ final class PilotCertificationViewModel { ) } + /// 创建EditRequest实例。 private func makeEditRequest() -> FlyerEditRequest { let apply = makeApplyRequest() return FlyerEditRequest( @@ -237,6 +242,7 @@ final class PilotCertificationViewModel { ) } + /// statusName相关逻辑。 private static func statusName(for status: Int) -> String { switch status { case 1: "审核中" @@ -254,6 +260,7 @@ final class PilotCertificationViewModel { return formatter }() + /// date 业务逻辑。 private static func date(from value: String) -> Date? { let text = String(value.prefix(10)) guard !text.isEmpty else { return nil } @@ -261,9 +268,11 @@ final class PilotCertificationViewModel { } } +/// PilotEmptyRealName服务协议,定义模块对外能力。 private struct PilotEmptyRealNameServing: PilotRealNameServing { let info: RealNameInfo? + /// realNameInfo相关逻辑。 func realNameInfo() async throws -> RealNameInfoResponse { RealNameInfoResponse(realNameInfo: info) } diff --git a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift index 40bd878..64c9e0d 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift @@ -6,8 +6,18 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias AccountSwitchSection = Int +private typealias AccountSwitchItem = String + +/// 账号切换列表空态标识。 +private enum AccountSwitchItemID { + static let empty = "accountSwitch:empty" +} + /// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。 -final class AccountSwitchViewController: UIViewController { +final class AccountSwitchViewController: UIViewController, UITableViewDelegate { private let viewModel = AccountSwitchViewModel() @@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController { let table = UITableView(frame: .zero, style: .plain) table.backgroundColor = UIColor(hex: 0xF5F7FB) table.separatorStyle = .none - table.dataSource = self table.delegate = self table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID) return table @@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController { return button }() + /// Diffable 数据源,驱动账号列表与选中态刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "账号切换" view.backgroundColor = UIColor(hex: 0xF5F7FB) + configureTableDataSource() view.addSubview(tableView) view.addSubview(confirmButton) tableView.snp.makeConstraints { make in @@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController { } viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applyTableSnapshot(reconfigure: true) self?.updateConfirmButton() } Task { await loadAccounts() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSwitchItem) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + if item == AccountSwitchItemID.empty { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView( + title: "暂无可切换账号", + message: "当前登录账号下没有其他可切换账号。", + systemImage: "person.crop.circle.badge.exclamationmark" + ) + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) } + return cell + } + guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else { + return UITableViewCell() + } + let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell + cell.configure( + account: account, + selected: self.viewModel.selectedAccountId == account.id, + isCurrent: account.isCurrent || self.isCurrentAccount(account) + ) + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.accounts.isEmpty, !viewModel.loading { + snapshot.appendItems([AccountSwitchItemID.empty], toSection: 0) + } else { + snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 更新ConfirmButton状态。 private func updateConfirmButton() { let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching confirmButton.isEnabled = enabled confirmButton.alpha = enabled ? 1 : 0.5 } + /// 加载Accounts数据。 private func loadAccounts(force: Bool = false) async { do { try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") { @@ -82,10 +153,12 @@ final class AccountSwitchViewController: UIViewController { return current.userId.isEmpty ? nil : current.userId } + /// 判断isCurrentAccount条件。 private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { viewModel.isCurrent(account, currentAccountId: currentAccountId) } + /// 点击confirm的处理逻辑。 @objc private func confirmTapped() { guard let account = viewModel.selectedAccount else { return } if account.isCurrent || isCurrentAccount(account) { @@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController { Task { await switchAccount(account) } } + /// switch账号相关逻辑。 private func switchAccount(_ account: AccountSwitchAccount) async { do { let response = try await appServices.globalLoading.withLoading(message: "切换中...") { @@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController { } } + /// non空相关逻辑。 private func nonEmpty(_ value: String?) -> String? { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } -} - -extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard !viewModel.accounts.isEmpty else { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView( - title: "暂无可切换账号", - message: "当前登录账号下没有其他可切换账号。", - systemImage: "person.crop.circle.badge.exclamationmark" - ) - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) } - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell - let account = viewModel.accounts[indexPath.row] - cell.configure( - account: account, - selected: viewModel.selectedAccountId == account.id, - isCurrent: account.isCurrent || isCurrentAccount(account) - ) - return cell - } + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.row < viewModel.accounts.count else { return } - viewModel.select(viewModel.accounts[indexPath.row]) + guard let item = tableDataSource.itemIdentifier(for: indexPath), + item != AccountSwitchItemID.empty, + let account = viewModel.accounts.first(where: { $0.id == item }) else { return } + viewModel.select(account) } + /// UITableView 代理:行高。 func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - viewModel.accounts.isEmpty ? 280 : 92 + guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return 92 } + return item == AccountSwitchItemID.empty ? 280 : 92 } } +/// AccountSwitch列表或网格 Cell,负责单项内容展示。 private final class AccountSwitchCell: UITableViewCell { static let reuseID = "AccountSwitchCell" @@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell { private let tagLabel = UILabel() private let checkView = UIImageView() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) { let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = title.isEmpty ? account.accountTypeLabel : title diff --git a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift index 99043cf..aca90d2 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift @@ -7,8 +7,29 @@ import PhotosUI import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias ProfileTableSection = Int +private typealias ProfileTableRow = String + +/// 个人信息页 section 索引。 +private enum ProfileSectionID { + static let info = 0 + static let logout = 1 +} + +/// 个人信息页行标识。 +private enum ProfileRowID { + static let name = "profile:name" + static let account = "profile:account" + static let phone = "profile:phone" + static let realName = "profile:realName" + static let settings = "profile:settings" + static let logout = "profile:logout" +} + /// 个人信息页,展示用户资料、账号状态和设置入口。 -final class ProfileViewController: UIViewController { +final class ProfileViewController: UIViewController, UITableViewDelegate { private let viewModel = ProfileViewModel() @@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self return table }() private lazy var refreshControl = UIRefreshControl() + /// Diffable 数据源,驱动个人信息列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "个人信息" view.backgroundColor = UIColor(hex: 0xF7FAFF) setupHeader() + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(150) @@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController { Task { await reloadProfile(showToast: false) } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + switch row { + case ProfileRowID.logout: + cell.textLabel?.text = "退出登录" + cell.textLabel?.textColor = UIColor(hex: 0xEF4444) + cell.textLabel?.textAlignment = .center + cell.selectionStyle = .default + case ProfileRowID.name: + cell.textLabel?.text = "姓名" + cell.detailTextLabel?.text = self.viewModel.displayRealName + cell.selectionStyle = .none + case ProfileRowID.account: + cell.textLabel?.text = "当前账号" + cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.phone: + cell.textLabel?.text = "手机号" + cell.detailTextLabel?.text = self.viewModel.displayPhone + cell.selectionStyle = .none + case ProfileRowID.realName: + cell.textLabel?.text = "实名认证" + cell.detailTextLabel?.text = self.viewModel.realNameStatusText + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.settings: + cell.textLabel?.text = "设置" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + default: + break + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([ProfileSectionID.info, ProfileSectionID.logout]) + snapshot.appendItems([ + ProfileRowID.name, + ProfileRowID.account, + ProfileRowID.phone, + ProfileRowID.realName, + ProfileRowID.settings + ], toSection: ProfileSectionID.info) + snapshot.appendItems([ProfileRowID.logout], toSection: ProfileSectionID.logout) + return snapshot + } + + /// 应用 snapshot 刷新列表,必要时重配 Cell 内容。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 初始化Header相关 UI 或状态。 private func setupHeader() { let header = UIView() header.backgroundColor = .white @@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController { } } + /// apply视图模型相关逻辑。 private func applyViewModel() { nicknameLabel.text = viewModel.displayNickname uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")" @@ -111,9 +205,10 @@ final class ProfileViewController: UIViewController { } editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal) editButton.isEnabled = !viewModel.isSaving - tableView.reloadData() + applyTableSnapshot(animated: false, reconfigure: true) } + /// 刷新Pulled展示。 @objc private func refreshPulled() { Task { await reloadProfile(showToast: true) @@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController { } } + /// 刷新Profile。 private func reloadProfile(showToast: Bool) async { do { try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") { @@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController { } } + /// 点击edit的处理逻辑。 @objc private func editTapped() { if viewModel.isEditingProfile { Task { await saveProfileEdits() } @@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController { } } + /// 弹出NicknameEditor页面。 private func presentNicknameEditor() { let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert) alert.addTextField { [weak self] field in @@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController { present(alert, animated: true) } + /// save个人中心Edits相关逻辑。 private func saveProfileEdits() async { guard let scenicId = appServices.accountContext.currentScenic?.id else { showToast("请先选择景区") @@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController { } } + /// pick头像相关逻辑。 @objc private func pickAvatar() { var config = PHPickerConfiguration() config.filter = .images @@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController { present(picker, animated: true) } + /// 点击logout的处理逻辑。 @objc private func logoutTapped() { let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController { }) present(alert, animated: true) } -} - -extension ProfileViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 5 : 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - if indexPath.section == 1 { - cell.textLabel?.text = "退出登录" - cell.textLabel?.textColor = UIColor(hex: 0xEF4444) - cell.textLabel?.textAlignment = .center - return cell - } - switch indexPath.row { - case 0: - cell.textLabel?.text = "姓名" - cell.detailTextLabel?.text = viewModel.displayRealName - case 1: - cell.textLabel?.text = "当前账号" - cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--" - cell.accessoryType = .disclosureIndicator - case 2: - cell.textLabel?.text = "手机号" - cell.detailTextLabel?.text = viewModel.displayPhone - case 3: - cell.textLabel?.text = "实名认证" - cell.detailTextLabel?.text = viewModel.realNameStatusText - cell.accessoryType = .disclosureIndicator - default: - cell.textLabel?.text = "设置" - cell.accessoryType = .disclosureIndicator - } - cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default - return cell - } + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1 { + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + switch row { + case ProfileRowID.logout: logoutTapped() - return - } - switch indexPath.row { - case 1: + case ProfileRowID.account: HomeMenuRouting.pushProfile(.accountSwitch, from: self) - case 3: + case ProfileRowID.realName: HomeMenuRouting.pushProfile(.realNameAuth, from: self) - case 4: + case ProfileRowID.settings: HomeMenuRouting.pushProfile(.settings, from: self) default: break @@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate { } extension ProfileViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } diff --git a/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift index 5b16955..f1200e6 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift @@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController { private let startDatePicker = UIDatePicker() private let endDatePicker = UIDatePicker() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "实名认证" @@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController { Task { await loadInfo() } } + /// 初始化Form相关 UI 或状态。 private func setupForm() { contentStack.axis = .vertical contentStack.spacing = 16 @@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController { contentStack.addArrangedSubview(submitButton) } + /// 创建SectionTitle实例。 private func makeSectionTitle(_ text: String) -> UILabel { let label = UILabel() label.text = text @@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController { return label } + /// labeled字段相关逻辑。 private func labeledField(_ title: String, _ field: UITextField) -> UIStackView { let label = UILabel() label.text = title @@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController { return stack } + /// apply视图模型相关逻辑。 private func applyViewModel() { realNameField.text = viewModel.realName idCardField.text = viewModel.idCardNo @@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController { } } + /// 加载Info数据。 private func loadInfo() async { do { try await appServices.globalLoading.withLoading(message: "加载中...") { @@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController { } } + /// longValid变更相关逻辑。 @objc private func longValidChanged() { viewModel.isLongValid = longValidSwitch.isOn } + /// 点击send验证码的处理逻辑。 @objc private func sendCodeTapped() { Task { do { @@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController { } } + /// 提交Tapped。 @objc private func submitTapped() { viewModel.realName = realNameField.text ?? "" viewModel.idCardNo = idCardField.text ?? "" @@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController { private var pendingSide: RealNameImageSide = .front + /// pickFront相关逻辑。 @objc private func pickFront() { pendingSide = .front presentImagePicker() } + /// pickBack相关逻辑。 @objc private func pickBack() { pendingSide = .back presentImagePicker() } + /// 弹出ImagePicker页面。 private func presentImagePicker() { var config = PHPickerConfiguration() config.filter = .images @@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController { } extension RealNameAuthViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } @@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate { } private extension UILabel { + /// 初始化实例。 convenience init(text: String) { self.init() self.text = text diff --git a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift index 6082e19..4074096 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift @@ -7,64 +7,81 @@ import SnapKit import UIKit import WebKit +// MARK: - Diffable 标识 + +/// 设置页行标识。 +private enum SettingsRowID { + static let about = "settings:about" + static let version = "settings:version" + static let download = "settings:download" + static let userAgreement = "settings:userAgreement" + static let privacyPolicy = "settings:privacyPolicy" +} + +/// 设置页行操作类型。 +private enum SettingsRowAction { + case agreement(AgreementPage) + case version + case download +} + /// 设置中心页面。 -final class SettingsViewController: UIViewController { +final class SettingsViewController: SimpleTableDiffableViewController { private var copiedDownloadLink = false - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self - table.delegate = self - return table - }() - - private let rows: [(title: String, action: SettingsRowAction)] = [ - ("关于我们", .agreement(.about)), - ("系统版本", .version), - ("App下载", .download), - ("用户协议", .agreement(.userAgreement)), - ("隐私政策", .agreement(.privacyPolicy)) + private let rowActions: [String: SettingsRowAction] = [ + SettingsRowID.about: .agreement(.about), + SettingsRowID.version: .version, + SettingsRowID.download: .download, + SettingsRowID.userAgreement: .agreement(.userAgreement), + SettingsRowID.privacyPolicy: .agreement(.privacyPolicy) ] - private enum SettingsRowAction { - case agreement(AgreementPage) - case version - case download - } + private let rowTitles: [String: String] = [ + SettingsRowID.about: "关于我们", + SettingsRowID.version: "系统版本", + SettingsRowID.download: "App下载", + SettingsRowID.userAgreement: "用户协议", + SettingsRowID.privacyPolicy: "隐私政策" + ] + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "设置" - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } } - private var downloadLink: String { - APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + /// 构建 Diffable snapshot。 + override func buildSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems([ + SettingsRowID.about, + SettingsRowID.version, + SettingsRowID.download, + SettingsRowID.userAgreement, + SettingsRowID.privacyPolicy + ], toSection: 0) + return snapshot } - private func copyDownloadLink() { - UIPasteboard.general.string = downloadLink - copiedDownloadLink = true - showToast("下载链接已复制") - tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.copiedDownloadLink = false - self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) - } + /// section 页脚文案。 + override func sectionFooter(for section: SimpleTableSection) -> String? { + "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" } -} -extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - let row = rows[indexPath.row] - cell.textLabel?.text = row.title + /// 配置 Cell 展示内容。 + override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) { + cell.textLabel?.text = rowTitles[row] cell.textLabel?.textColor = UIColor(hex: 0x4B5563) - switch row.action { + cell.detailTextLabel?.text = nil + cell.detailTextLabel?.textColor = nil + cell.accessoryType = .none + cell.selectionStyle = .default + + guard let action = rowActions[row] else { return } + switch action { case .version: cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText() cell.selectionStyle = .none @@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { case .agreement: cell.accessoryType = .disclosureIndicator } - return cell } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - switch rows[indexPath.row].action { + /// 处理行选中。 + override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) { + guard let action = rowActions[row] else { return } + switch action { case .agreement(let page): HomeMenuRouting.pushProfile(.agreement(page), from: self) case .download: @@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { } } - func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" + private var downloadLink: String { + APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + } + + /// 复制下载链接并刷新对应行展示。 + private func copyDownloadLink() { + UIPasteboard.general.string = downloadLink + copiedDownloadLink = true + showToast("下载链接已复制") + var snapshot = tableDataSource.snapshot() + snapshot.reconfigureItems([SettingsRowID.download]) + tableDataSource.apply(snapshot, animatingDifferences: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + self.copiedDownloadLink = false + var snapshot = self.tableDataSource.snapshot() + snapshot.reconfigureItems([SettingsRowID.download]) + self.tableDataSource.apply(snapshot, animatingDifferences: false) + } } } @@ -101,6 +135,7 @@ final class AgreementViewController: UIViewController { private let webView = WKWebView(frame: .zero) private let loadingIndicator = UIActivityIndicatorView(style: .large) + /// 初始化实例。 init(page: AgreementPage) { self.page = page super.init(nibName: nil, bundle: nil) @@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = page.title @@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController { } extension AgreementViewController: WKNavigationDelegate { + /// web视图相关逻辑。 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { loadingIndicator.stopAnimating() } + /// web视图相关逻辑。 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { loadingIndicator.stopAnimating() showToast(error.localizedDescription) diff --git a/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift index 453973a..80a786b 100644 --- a/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift +++ b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class ProjectManagementViewController: ModuleTableViewController { private let viewModel = ProjectManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "项目管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)") } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] navigationController?.pushViewController( @@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId) } + /// 创建项目。 @objc private func createProject() { navigationController?.pushViewController( ProjectEditorViewController(projectId: nil), @@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {} final class StoreProjectManagementViewController: ModuleTableViewController { private let viewModel = StoreProjectManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "店铺项目" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredItems.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)") } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] navigationController?.pushViewController( @@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.projectAPI, userId: services.userId) } @@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController { private let photographerViewModel = ProjectManagementViewModel() private let storeViewModel = StoreProjectManagementViewModel() + /// 初始化实例。 init(projectId: Int, storeMode: Bool) { self.projectId = projectId self.storeMode = storeMode @@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "项目详情" super.viewDidLoad() @@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController { storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { detail == nil ? 0 : 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { guard let detail else { return } switch indexPath.row { @@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { if storeMode { await storeViewModel.loadDetail(id: projectId, api: services.projectAPI) @@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController { private let priceField = UITextField() private let projectId: Int? + /// 初始化实例。 init(projectId: Int?) { self.projectId = projectId super.init(nibName: nil, bundle: nil) @@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = projectId == nil ? "新建项目" : "编辑项目" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { nameField.placeholder = "项目名称" nameField.borderStyle = .roundedRect @@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 0 } + /// 刷新Content。 override func reloadContent() async { guard let projectId else { return } if let detail = try? await services.projectAPI.projectDetail(id: projectId) { @@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController { } } + /// 按需fill表单。 private func fillFormIfNeeded() { if nameField.text?.isEmpty != false { nameField.text = viewModel.name } if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText } if priceField.text?.isEmpty != false { priceField.text = viewModel.price } } + /// save 业务逻辑。 @objc private func save() { viewModel.name = nameField.text ?? "" viewModel.descriptionText = descriptionField.text ?? "" @@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController { private let viewModel = StoreProjectEditorViewModel(mode: .create) private let nameField = UITextField() + /// 初始化实例。 init(projectId: Int?) { super.init(nibName: nil, bundle: nil) } @@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "店铺项目" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.scenicList.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let scenic = viewModel.scenicList[indexPath.row] cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil) } + /// 刷新Content。 override func reloadContent() async { guard let userId = Int(services.userId ?? "") else { return } await viewModel.loadScenicList(api: services.projectAPI, userId: userId) } + /// save 业务逻辑。 @objc private func save() { viewModel.name = nameField.text ?? "" Task { diff --git a/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift index d2cabf0..d0e2e90 100644 --- a/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift +++ b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift @@ -130,6 +130,7 @@ final class ProjectManagementViewModel { return value.isEmpty ? nil : value } + /// 重置List状态。 private func resetList() { items = [] loading = false @@ -139,6 +140,7 @@ final class ProjectManagementViewModel { total = 0 } + /// sorted 业务逻辑。 private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { values.sorted { lhs, rhs in if lhs.status != rhs.status { return lhs.status < rhs.status } @@ -146,6 +148,7 @@ final class ProjectManagementViewModel { } } + /// deduplicated 业务逻辑。 private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { var result: [Int: PhotographerProjectItem] = [:] values.forEach { result[$0.id] = $0 } @@ -278,6 +281,7 @@ final class ProjectEditorViewModel { return labels.isEmpty ? nil : labels } + /// resolveCover链接相关逻辑。 private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { if let coverImage { return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in @@ -287,6 +291,7 @@ final class ProjectEditorViewModel { return existingCoverURL } + /// resolveCarouselURLs相关逻辑。 private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { var urls = existingCarouselURLs for image in carouselImages { @@ -298,11 +303,13 @@ final class ProjectEditorViewModel { return urls } + /// 规范化dMoney格式。 private func normalizedMoney(_ value: String) -> String? { let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 return amount > 0 ? String(format: "%.2f", amount) : nil } + /// 记录校验失败并返回 false。 private func fail(_ error: ProjectEditorError) -> Bool { errorMessage = error.localizedDescription return false @@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel { return nil } + /// resolveCover链接相关逻辑。 private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { if let coverImage { return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in } @@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel { return existingCoverURL } + /// resolveCarouselURLs相关逻辑。 private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { var urls = existingCarouselURLs for image in carouselImages { @@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel { return urls } + /// 提交MultiPointProject。 private func submitMultiPointProject( userId: Int, api: any ProjectServing, @@ -586,6 +596,7 @@ final class StoreProjectEditorViewModel { } } + /// 提交OfflineProject。 private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws { let scenicId = selectedScenicIds.first ?? 0 let storeId = selectedStoreId ?? 0 @@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel { } } + /// 记录校验失败并返回 false。 private func fail(_ error: ProjectEditorError) -> Bool { errorMessage = error.localizedDescription return false diff --git a/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift index 8ad66e6..5fad5fd 100644 --- a/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift +++ b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift @@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable { var address: String var scenicSpotStr: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case lat case lot @@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable { let auditTime: String let auditRemark: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicAreaId = "scenic_area_id" @@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable { let scenicSpotStr: String let guideImages: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicAreaId = "scenic_area_id" case name @@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable { let scenicSpotStr: String let guideImages: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicAreaId = "scenic_area_id" diff --git a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift index bdff60d..80a2931 100644 --- a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift +++ b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift @@ -5,6 +5,7 @@ // Created by Codex on 2026/6/26. // +import CoreLocation import SnapKit import UIKit @@ -18,6 +19,7 @@ private extension PunchPointItem { final class PunchPointListViewController: ModuleTableViewController { private let viewModel = PunchPointListViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "打卡点" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] navigationController?.pushViewController( @@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI) } + /// 创建打卡Point。 @objc private func createPunchPoint() { navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true) } @@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController { private let summary: PunchPointItem? private let viewModel = PunchPointListViewModel() + /// 初始化实例。 init(punchPointId: Int, summary: PunchPointItem?) { self.punchPointId = punchPointId self.summary = summary @@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = summary?.name ?? "打卡点详情" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { guard viewModel.selectedDetail != nil || summary != nil else { return 0 } return 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let detail = viewModel.selectedDetail ?? summary guard let detail else { return } @@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI) } + /// 展示QR。 @objc private func showQR() { guard let detail = viewModel.selectedDetail ?? summary else { return } navigationController?.pushViewController( @@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController { } /// 打卡点编辑页。 -final class PunchPointEditorViewController: ModuleTableViewController { +final class PunchPointEditorViewController: UIViewController { private let viewModel = PunchPointEditorViewModel() - private let nameField = UITextField() private let punchPointId: Int? + private let nameField = UITextField() + private let addressField = UITextField() + private let latitudeField = UITextField() + private let longitudeField = UITextField() + private let mapPicker = PunchPointMapPickerView() + private let locationProvider = ForegroundLocationProvider() + + private var services: AppServices { AppServices.shared } init(punchPointId: Int?) { self.punchPointId = punchPointId @@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController { } override func viewDidLoad() { + super.viewDidLoad() title = punchPointId == nil ? "新建打卡点" : "编辑打卡点" + view.backgroundColor = UIColor(hex: 0xF5F7FA) navigationItem.rightBarButtonItem = UIBarButtonItem( title: "保存", style: .done, target: self, action: #selector(save) ) - super.viewDidLoad() - nameField.placeholder = "打卡点名称" - nameField.borderStyle = .roundedRect - nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) - tableView.tableHeaderView = nameField - wireViewModel(viewModel) { [weak self] in - if self?.nameField.text?.isEmpty != false { - self?.nameField.text = self?.viewModel.name + + configureFields() + layoutForm() + wireViewModel() + + mapPicker.onLocationPicked = { [weak self] lat, lng, address in + self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address) + self?.syncFieldsFromViewModel() + } + + Task { + guard let punchPointId else { return } + if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { + viewModel.apply(detail) + syncFieldsFromViewModel() } } } - override func tableRowCount() -> Int { 1 } - - override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { - cell.configure(title: "地址", subtitle: viewModel.address) + private func configureFields() { + [nameField, addressField, latitudeField, longitudeField].forEach { + $0.borderStyle = .roundedRect + $0.font = .systemFont(ofSize: AppMetrics.FontSize.body) + } + nameField.placeholder = "打卡点名称" + addressField.placeholder = "地址" + latitudeField.placeholder = "纬度" + longitudeField.placeholder = "经度" + latitudeField.keyboardType = .decimalPad + longitudeField.keyboardType = .decimalPad } - override func reloadContent() async { - guard let punchPointId else { return } - if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { - viewModel.apply(detail) - nameField.text = viewModel.name + private func layoutForm() { + let locateButton = UIButton(type: .system) + locateButton.setTitle("使用当前位置", for: .normal) + locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [ + nameField, + mapPicker, + addressField, + latitudeField, + longitudeField, + locateButton + ]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.small + + view.addSubview(stack) + nameField.snp.makeConstraints { make in make.height.equalTo(44) } + mapPicker.snp.makeConstraints { make in make.height.equalTo(220) } + addressField.snp.makeConstraints { make in make.height.equalTo(44) } + latitudeField.snp.makeConstraints { make in make.height.equalTo(44) } + longitudeField.snp.makeConstraints { make in make.height.equalTo(44) } + locateButton.snp.makeConstraints { make in make.height.equalTo(44) } + + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium) + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + } + } + + private func wireViewModel() { + viewModel.onChange = { [weak self] in + self?.syncFieldsFromViewModel() + } + } + + private func syncFieldsFromViewModel() { + if nameField.text?.isEmpty != false { nameField.text = viewModel.name } + addressField.text = viewModel.address + latitudeField.text = viewModel.latitudeText + longitudeField.text = viewModel.longitudeText + if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) { + mapPicker.setCoordinate(latitude: lat, longitude: lng) + } + } + + @objc private func useCurrentLocation() { + Task { + do { + let result = try await locationProvider.requestCurrentLocation() + viewModel.applyLocation( + latitude: result.latitude, + longitude: result.longitude, + address: result.address + ) + syncFieldsFromViewModel() + } catch { + services.toastCenter.show("定位失败:\(error.localizedDescription)") + } } } @objc private func save() { viewModel.name = nameField.text ?? "" + viewModel.address = addressField.text ?? "" + viewModel.latitudeText = latitudeField.text ?? "" + viewModel.longitudeText = longitudeField.text ?? "" Task { let success = await viewModel.submit( scenicId: services.currentScenicId, api: services.punchPointAPI, uploadService: services.ossUploadService ) - if success { navigationController?.popViewController(animated: true) } + if success { + services.toastCenter.show("保存成功") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } } } } @@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController { private let pageTitle: String private let qrURL: String + /// 初始化实例。 init(title: String, qrURL: String) { pageTitle = title self.qrURL = qrURL @@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = pageTitle diff --git a/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift index a1789a9..2333503 100644 --- a/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift +++ b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift @@ -10,17 +10,29 @@ import Foundation /// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。 @MainActor protocol ScenicQueueServing: AnyObject { + /// scenic排队统计相关逻辑。 func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData + /// scenic排队Home相关逻辑。 func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData + /// socket令牌相关逻辑。 func socketToken() async throws -> SocketTokenResponse + /// scenic排队Call相关逻辑。 func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData + /// scenic排队Pass相关逻辑。 func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData + /// scenic排队Finish相关逻辑。 func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData + /// scenic排队RequeueInsertBefore相关逻辑。 func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws + /// scenic排队UserMark相关逻辑。 func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws + /// scenic排队Setting相关逻辑。 func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData + /// scenic排队保存Setting相关逻辑。 func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws + /// scenic排队Shoot排队QRCode相关逻辑。 func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData + /// scenic排队SettingChangeLog相关逻辑。 func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData } diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift index 0da1016..3104519 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift @@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable { let avgWaitMin: Double let time: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case queueCount = "queue_count" case avgWaitMin = "avg_wait_min" @@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable { let list: ScenicQueueHomeListBlock? let time: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case stats case list @@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable { struct ScenicQueueHomeStats: Decodable, Equatable { let type: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case type } @@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case list case total @@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { isMissRequeue == 1 } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case queueCode = "queue_code" @@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { case missRequeueText = "is_miss_requeue_text" } + /// 枚举,定义相关常量或状态。 enum AlternateCodingKeys: String, CodingKey { case queueNo = "queue_no" case queueNumber = "queue_number" @@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines) } + /// 格式化排队时间。 private static func formatQueueTime(_ raw: String) -> String { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return "--" } @@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable { struct SocketTokenResponse: Decodable, Equatable { let socketToken: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case socketToken = "socket_token" } @@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable { let statusText: String let calledAt: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case status @@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable { let recordId: Int64 let operatorId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case recordId = "record_id" case operatorId = "operator_id" @@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable { let queueBanDays: Int? let operatorId: Int? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case uid case scenicId = "scenic_id" @@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable { } private extension KeyedDecodingContainer { + /// 解码宽松字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -449,6 +461,7 @@ private extension KeyedDecodingContainer { return "" } + /// 解码宽松整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value @@ -467,6 +480,7 @@ private extension KeyedDecodingContainer { return nil } + /// 解码宽松Double。 func decodeLossyDouble(forKey key: Key) throws -> Double? { if let value = try? decodeIfPresent(Double.self, forKey: key) { return value @@ -484,6 +498,7 @@ private extension KeyedDecodingContainer { } private extension String { + /// non空或默认相关逻辑。 func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift index 2bfac30..70e153a 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift @@ -13,6 +13,7 @@ struct ScenicQueueSettingData: Decodable, Equatable { let setting: ScenicQueueSettingItem? let time: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case exists case setting @@ -64,6 +65,7 @@ struct ScenicQueueSettingItem: Decodable, Equatable { let createdAt: String let updatedAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicId = "scenic_id" @@ -131,6 +133,7 @@ struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable { let content: String let sortOrder: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case content case sortOrder = "sort_order" @@ -175,6 +178,7 @@ struct ScenicQueueSaveSettingRequest: Encodable, Equatable { let status: Int let remark: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case scenicSpotId = "scenic_spot_id" @@ -208,6 +212,7 @@ struct ScenicQueueSettingChangeLogData: Decodable, Equatable { let pageSize: Int let list: [ScenicQueueSettingChangeLogItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case page @@ -243,6 +248,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { let displayText: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicSpotName = "scenic_spot_name" @@ -270,6 +276,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable { let qrcodeUrl: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case qrcodeUrl = "qrcode_url" } @@ -309,6 +316,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable { var businessStartTime: String = "10:00" var businessEndTime: String = "20:00" + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case shootMinute = "shoot_minute" case shootSecond = "shoot_second" @@ -334,6 +342,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable { } private extension KeyedDecodingContainer { + /// 解码宽松字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } @@ -342,6 +351,7 @@ private extension KeyedDecodingContainer { return "" } + /// 解码宽松整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } @@ -354,6 +364,7 @@ private extension KeyedDecodingContainer { return nil } + /// 解码宽松布尔。 func decodeLossyBool(forKey key: Key) throws -> Bool? { if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift index 792e58a..f32522a 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift @@ -155,17 +155,20 @@ enum ScenicQueueSettingsStore { UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)") } + /// 规范化dUserId格式。 private static func normalizedUserId(_ userId: String?) -> String { let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? "anonymous" : text } + /// positive整数相关逻辑。 private static func positiveInt(forKey key: String) -> Int? { guard UserDefaults.standard.object(forKey: key) != nil else { return nil } let value = UserDefaults.standard.integer(forKey: key) return value > 0 ? value : nil } + /// legacy正数整数相关逻辑。 private static func legacyPositiveInt(forKey key: String) -> Int? { positiveInt(forKey: key) } diff --git a/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift index e002d52..222e0cc 100644 --- a/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift +++ b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class QueueManagementViewController: ModuleTableViewController { private let viewModel = QueueManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排队管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,13 +24,16 @@ final class QueueManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload( api: services.scenicQueueAPI, @@ -40,10 +44,12 @@ final class QueueManagementViewController: ModuleTableViewController { updateTitle() } + /// 更新Title状态。 private func updateTitle() { title = "排队 \(viewModel.queueCount)" } + /// open设置相关逻辑。 @objc private func openSettings() { navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) } @@ -55,14 +61,17 @@ extension QueueManagementViewModel: ViewModelBindable {} final class ScenicQueueSettingsViewController: ModuleTableViewController { private let viewModel = ScenicQueueSettingsViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排队设置" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.scenicSpots.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let spot = viewModel.scenicSpots[indexPath.row] let selected = viewModel.selectedSpotId == spot.id @@ -70,10 +79,12 @@ final class ScenicQueueSettingsViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id } + /// 刷新Content。 override func reloadContent() async { await viewModel.load( api: services.scenicQueueAPI, diff --git a/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift index 4a1c696..d767ee2 100644 --- a/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift +++ b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift @@ -209,6 +209,7 @@ final class QueueManagementViewModel { return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0) } + /// 处理SocketMessage相关事件。 private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { guard message.isScenicQueueEvent, let params = message.data?.params, @@ -227,6 +228,7 @@ final class QueueManagementViewModel { } } + /// 处理RemoteTicketCalled相关事件。 private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { guard let recordId = params.recordId, recordId > 0 else { return } if let operatorUid = params.operatorUid, @@ -251,6 +253,7 @@ final class QueueManagementViewModel { await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) } + /// 加载QueueingTickets数据。 private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] { let home = try await api.scenicQueueHome( scenicId: scenicId, @@ -262,6 +265,7 @@ final class QueueManagementViewModel { return home.list?.list ?? [] } + /// consume待处理远程CalledTickets相关逻辑。 private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) { guard let ticket = tickets.first(where: { pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号")) @@ -272,6 +276,7 @@ final class QueueManagementViewModel { remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode) } + /// 加载Page数据。 private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws { guard let scenicSpotId = selectedSpotId else { return } let home = try await api.scenicQueueHome( @@ -300,6 +305,7 @@ final class QueueManagementViewModel { refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId) } + /// mark排队Called相关逻辑。 private func markQueueCalled(id: Int64, statusText: String) { let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines) items = items.map { item in @@ -330,6 +336,7 @@ final class QueueManagementViewModel { } } + /// 重置状态。 private func reset() { stopRealtime() loading = false @@ -343,6 +350,7 @@ final class QueueManagementViewModel { clearQueueData() } + /// 清空排队数据。 private func clearQueueData() { loadingMore = false items = [] @@ -354,6 +362,7 @@ final class QueueManagementViewModel { lastSyncTimeText = "--" } + /// 刷新ShootingCallConfig展示。 private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) { let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() showStartShootingButton = snapshot.showStartShootingButton @@ -362,6 +371,7 @@ final class QueueManagementViewModel { prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled } + /// 同步QueueSettingFromServerIfMatchesLocal状态。 private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async { guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId), data.exists, @@ -648,6 +658,7 @@ final class ScenicQueueSettingsViewModel { presetVoices.remove(at: index) } + /// 重置SettingsState状态。 private func resetSettingsState() { loading = false scenicSpots = [] @@ -678,6 +689,7 @@ final class ScenicQueueSettingsViewModel { qrcodeURL = "" } + /// apply 业务逻辑。 private func apply(_ setting: ScenicQueueSettingItem?) { guard let setting else { return } if let spotId = setting.scenicSpotId { selectedSpotId = spotId } @@ -707,6 +719,7 @@ final class ScenicQueueSettingsViewModel { if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) } } + /// apply 业务逻辑。 private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))" photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))" @@ -730,6 +743,7 @@ final class ScenicQueueSettingsViewModel { businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) } + /// 规范化dMaxQueueRangeForDisplay格式。 private func normalizedMaxQueueRangeForDisplay() throws -> String { let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm) guard !normalized.isEmpty, @@ -745,12 +759,14 @@ final class ScenicQueueSettingsViewModel { return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue) } + /// queueDistanceMeter用于提交相关逻辑。 private func queueDistanceMeterForSubmit() throws -> Int { _ = try normalizedMaxQueueRangeForDisplay() let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0 return NSDecimalNumber(decimal: value * Decimal(1000)).intValue } + /// 加载QRCodeImage数据。 private func loadQRCodeImage(from text: String) async throws -> UIImage { if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) { let (data, _) = try await URLSession.shared.data(from: url) @@ -760,6 +776,7 @@ final class ScenicQueueSettingsViewModel { throw APIError.networkFailed("二维码图片生成失败") } + /// generateQRCode相关逻辑。 private static func generateQRCode(from string: String) -> UIImage? { let filter = CIFilter.qrCodeGenerator() filter.message = Data(string.utf8) @@ -770,12 +787,14 @@ final class ScenicQueueSettingsViewModel { return UIImage(cgImage: cgImage) } + /// saveTiming快照相关逻辑。 private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) { UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey) UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey) UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey) } + /// saveShootingCall设置相关逻辑。 private func saveShootingCallSettings(autoCallAhead: Int) { UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey) UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey) @@ -783,10 +802,12 @@ final class ScenicQueueSettingsViewModel { UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey) } + /// save自定义文本Locally相关逻辑。 private func saveCustomTextLocally() { ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId) } + /// voiceBroadcasts用于保存载荷相关逻辑。 private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] { Array(presetVoices .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -796,6 +817,7 @@ final class ScenicQueueSettingsViewModel { .map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) }) } + /// 创建Snapshot实例。 private func makeSnapshot( min: Int? = nil, sec: Int? = nil, @@ -832,18 +854,22 @@ final class ScenicQueueSettingsViewModel { ) } + /// coerced播报间隔相关逻辑。 static func coercedBroadcastInterval(_ raw: Int) -> Int { (40...60).contains(raw) ? raw : 50 } + /// coerced倒计时阈值相关逻辑。 static func coercedCountdownThreshold(_ raw: Int) -> Int { (10...30).contains(raw) ? raw : 15 } + /// business时间相关逻辑。 static func businessTime(hour: Int, minute: Int) -> Date { Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() } + /// 解析BusinessTime数据。 static func parseBusinessTime(_ raw: String, fallback: Date) -> Date { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return fallback } @@ -856,6 +882,7 @@ final class ScenicQueueSettingsViewModel { return fallback } + /// business时间载荷相关逻辑。 static func businessTimePayload(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -863,6 +890,7 @@ final class ScenicQueueSettingsViewModel { return formatter.string(from: date) } + /// queueDistanceDisplay相关逻辑。 private static func queueDistanceDisplay(meters: Int) -> String { let clamped = min(max(meters, 0), 9_999_990) return String(format: "%.2f", Double(clamped) / 1000) @@ -875,6 +903,7 @@ private extension String { return text.isEmpty ? nil : text } + /// non空或默认相关逻辑。 func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text diff --git a/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift index 755928d..3538f87 100644 --- a/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift +++ b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift @@ -14,6 +14,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable { let name: String let children: [ScenicAreaNode] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -44,6 +45,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable { struct ScenicApplicationPendingsResponse: Decodable, Equatable { let items: [ScenicApplicationPendingResponse] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case list @@ -83,6 +85,7 @@ struct ScenicApplicationPendingResponse: Decodable, Equatable, Identifiable { let auditNote: String? let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -171,6 +174,7 @@ struct ScenicApplicationSubmitRequest: Encodable, Equatable { let remark: String let scenicId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicName = "scenic_name" case scenicImages = "scenic_images" @@ -196,6 +200,7 @@ struct RoleApplyPendingResponse: Decodable, Equatable, Hashable, Identifiable { let auditedAt: String? let auditNote: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -259,6 +264,7 @@ struct RoleApplyScenicItem: Decodable, Equatable, Hashable, Identifiable { let id: Int let name: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -283,6 +289,7 @@ struct RoleApplySubmitRequest: Encodable, Equatable { let scenicId: [Int] let roleId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case roleId = "role_id" @@ -295,6 +302,7 @@ struct ScenicApplicationUploadPlaceholder: Encodable, Equatable { let fileType: String let fileSize: Int64 + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileType = "file_type" diff --git a/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift index c939f43..912c92f 100644 --- a/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift +++ b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift @@ -5,20 +5,44 @@ // Created by Codex on 2026/6/26. // +import CoreLocation import UIKit /// 景区选择页。 final class ScenicSelectionViewController: ModuleTableViewController { private let viewModel = ScenicSelectionViewModel() private let searchBar = UISearchBar() + private let locationProvider = ScenicSelectionLocationProvider() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "选择景区" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "定位", + style: .plain, + target: self, + action: #selector(requestLocation) + ) super.viewDidLoad() setupSearchBar() wireViewModel(viewModel) { } } + @objc private func requestLocation() { + viewModel.currentLocationText = "定位中..." + locationProvider.onLocation = { [weak self] location in + self?.viewModel.applyCurrentLocation( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + } + locationProvider.onFailure = { [weak self] message in + self?.services.toastCenter.show(message) + } + locationProvider.request() + } + + /// 初始化SearchBar相关 UI 或状态。 private func setupSearchBar() { searchBar.placeholder = "搜索景区" searchBar.delegate = self @@ -26,15 +50,18 @@ final class ScenicSelectionViewController: ModuleTableViewController { tableView.tableHeaderView = searchBar } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredItems.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] cell.configure(title: item.name, subtitle: item.address, detail: item.distanceText) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] viewModel.select( @@ -46,6 +73,7 @@ final class ScenicSelectionViewController: ModuleTableViewController { navigationController?.popViewController(animated: true) } + /// 刷新Content。 override func reloadContent() async { viewModel.reload(from: services.accountContext) } @@ -54,6 +82,7 @@ final class ScenicSelectionViewController: ModuleTableViewController { extension ScenicSelectionViewModel: ViewModelBindable {} extension ScenicSelectionViewController: UISearchBarDelegate { + /// searchBar相关逻辑。 func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { viewModel.searchQuery = searchText } @@ -63,6 +92,7 @@ extension ScenicSelectionViewController: UISearchBarDelegate { final class PermissionApplyViewController: ModuleTableViewController { private let viewModel = PermissionApplyViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "权限申请" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -75,17 +105,21 @@ final class PermissionApplyViewController: ModuleTableViewController { wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? viewModel.roleOptions.count : viewModel.scenicOptions.count } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "选择角色" : "选择景区" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -102,8 +136,8 @@ final class PermissionApplyViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 0 { viewModel.selectRole(id: viewModel.roleOptions[indexPath.row].id) Task { await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) } @@ -112,6 +146,7 @@ final class PermissionApplyViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { viewModel.bootstrap(rolePermissions: services.permissionContext.rolePermissions) if viewModel.selectedRoleId != nil { @@ -119,6 +154,7 @@ final class PermissionApplyViewController: ModuleTableViewController { } } + /// 提交。 @objc private func submit() { Task { await viewModel.submit(api: services.scenicPermissionAPI) @@ -138,16 +174,19 @@ extension PermissionApplyViewModel: ViewModelBindable {} final class PermissionApplyStatusViewController: ModuleTableViewController { private let viewModel = PermissionApplyStatusViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "申请状态" super.viewDidLoad() viewModel.onChange = { [weak self] in self?.reloadTable() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.pending == nil ? 0 : 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { guard let pending = viewModel.pending else { return } switch indexPath.row { @@ -158,6 +197,7 @@ final class PermissionApplyStatusViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.scenicPermissionAPI, applyCode: nil) } @@ -168,6 +208,7 @@ final class ScenicApplicationViewController: ModuleTableViewController { private let viewModel = ScenicApplicationViewModel() private let nameField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "景区申请" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -188,8 +229,10 @@ final class ScenicApplicationViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 3 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { switch indexPath.row { case 0: cell.configure(title: "省份", subtitle: viewModel.selectedProvince) @@ -198,10 +241,12 @@ final class ScenicApplicationViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadInitial(api: services.scenicPermissionAPI) } + /// 提交。 @objc private func submit() { viewModel.scenicName = nameField.text ?? "" Task { diff --git a/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift index eb0a490..40fc8e3 100644 --- a/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift +++ b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift @@ -20,6 +20,7 @@ struct ScenicSettlementSubmitRequest: Encodable, Equatable { let applyAmount: String let applyRemark: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case applyAmount = "apply_amount" diff --git a/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift index a5b2930..b13a852 100644 --- a/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift +++ b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift @@ -13,6 +13,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { private let amountField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "景区结算" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -26,6 +27,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { amountField.placeholder = "结算金额" amountField.borderStyle = .roundedRect @@ -41,20 +43,24 @@ final class ScenicSettlementViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.options.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let option = viewModel.options[indexPath.row] cell.configure(title: option.name, subtitle: option.selected ? "已选择" : nil) cell.accessoryType = option.selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.toggleScenic(id: viewModel.options[indexPath.row].id) } + /// 刷新Content。 override func reloadContent() async { await viewModel.load( api: services.scenicPermissionAPI, @@ -62,6 +68,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { ) } + /// 提交。 @objc private func submit() { viewModel.amountText = amountField.text ?? "" viewModel.remarkText = remarkField.text ?? "" @@ -83,23 +90,28 @@ extension ScenicSettlementViewModel: ViewModelBindable {} final class ScenicSettlementReviewViewController: ModuleTableViewController { private let viewModel = ScenicSettlementReviewViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "结算审核" super.viewDidLoad() wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? viewModel.scenicApplications.count : viewModel.roleApplications.count } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "景区申请" : "权限申请" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -122,6 +134,7 @@ final class ScenicSettlementReviewViewController: ModuleTableViewController { return cell } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.scenicPermissionAPI) } diff --git a/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift index 314e878..6e0fdbd 100644 --- a/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift +++ b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class ScheduleManagementViewController: ModuleTableViewController { private let viewModel = ScheduleManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排班管理" navigationItem.rightBarButtonItems = [ @@ -27,10 +28,12 @@ final class ScheduleManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure( @@ -40,6 +43,7 @@ final class ScheduleManagementViewController: ModuleTableViewController { ) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] let alert = UIAlertController(title: item.name, message: "删除该排班?", preferredStyle: .alert) @@ -51,24 +55,29 @@ final class ScheduleManagementViewController: ModuleTableViewController { present(alert, animated: true) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.scheduleAPI, scenicId: services.currentScenicId) updateTitle() } + /// 更新Title状态。 private func updateTitle() { let month = viewModel.monthDate.scheduleYearMonthText title = "排班 \(month)" } + /// previousMonth相关逻辑。 @objc private func previousMonth() { Task { await viewModel.previousMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } } + /// nextMonth相关逻辑。 @objc private func nextMonth() { Task { await viewModel.nextMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } } + /// add排班相关逻辑。 @objc private func addSchedule() { navigationController?.pushViewController(ScheduleAddViewController(), animated: true) } @@ -82,6 +91,7 @@ final class ScheduleAddViewController: ModuleTableViewController { private let nameField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "新增排班" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -95,6 +105,7 @@ final class ScheduleAddViewController: ModuleTableViewController { setupFormHeader() } + /// 初始化FormHeader相关 UI 或状态。 private func setupFormHeader() { nameField.placeholder = "日程名称" nameField.borderStyle = .roundedRect @@ -109,10 +120,12 @@ final class ScheduleAddViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.availableOrders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let order = viewModel.availableOrders[indexPath.row] let selected = viewModel.draft.selectedOrder?.orderNumber == order.orderNumber @@ -120,15 +133,18 @@ final class ScheduleAddViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.draft.selectedOrder = viewModel.availableOrders[indexPath.row] reloadTable() } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadOrders(api: services.scheduleAPI, scenicId: services.currentScenicId) } + /// 提交。 @objc private func submit() { viewModel.draft.name = nameField.text ?? "" viewModel.draft.remark = remarkField.text ?? "" diff --git a/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift index 6ccce58..bdc74eb 100644 --- a/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift +++ b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift @@ -99,6 +99,7 @@ final class ScheduleManagementViewModel { } } + /// 重置状态。 private func reset() { markedDays = [] items = [] @@ -174,6 +175,7 @@ final class ScheduleAddViewModel { return text } + /// 记录校验失败并返回 false。 private func fail(_ error: ScheduleValidationError) -> Bool { errorMessage = error.localizedDescription return false diff --git a/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift index 5a577b7..2f99a21 100644 --- a/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift +++ b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift @@ -80,7 +80,7 @@ enum StatisticsPeriod: String, CaseIterable, Identifiable { } /// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。 -struct StatisticsSummaryResponse: Decodable, Equatable { +struct StatisticsSummaryResponse: Decodable, Equatable, Hashable { let orderAmountSum: String let orderCount: Int let orderPriceAverage: String @@ -92,6 +92,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) } var refundAmountValue: Double { Self.parseAmount(refundAmountSum) } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderAmountSum = "order_amount_sum" case orderCount = "order_count" @@ -125,6 +126,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum) } + /// 解析Amount数据。 private static func parseAmount(_ text: String) -> Double { let normalized = text.filter { "0123456789.-".contains($0) } return Double(normalized) ?? 0 @@ -132,7 +134,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { } /// 每日统计实体,表示某天订单数、客单价、退款和实收。 -struct StatisticsDailyItem: Decodable, Identifiable, Equatable { +struct StatisticsDailyItem: Decodable, Identifiable, Equatable, Hashable { var id: String { date } let date: String @@ -145,6 +147,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable { var refundValue: Double { Self.parseAmount(refund) } var receivedValue: Double { Self.parseAmount(received) } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case date case orderCount = "order_count" @@ -163,6 +166,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable { received = try container.decodeLossyString(forKey: .received) } + /// 解析Amount数据。 private static func parseAmount(_ text: String) -> Double { let normalized = text.filter { "0123456789.-".contains($0) } return Double(normalized) ?? 0 diff --git a/suixinkan_ios/Features/Statistics/Statistics.md b/suixinkan_ios/Features/Statistics/Statistics.md index d56a51e..0db0d3d 100644 --- a/suixinkan_ios/Features/Statistics/Statistics.md +++ b/suixinkan_ios/Features/Statistics/Statistics.md @@ -8,7 +8,7 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单 ## 核心对象 -- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。 +- `StatisticsViewController`:数据 Tab 根页面,使用 `UICollectionView` + Diffable Data Source 展示多 section 看板。 - `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。 - `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。 - `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。 @@ -24,3 +24,9 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单 ## 分页规则 每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。 + +## 列表实现 + +- Section:`empty`(无景区)、`period`(周期切换)、`summary`(汇总卡)、`daily`(每日明细,带 section 头「每日明细」) +- 使用 `UICollectionViewDiffableDataSource` + `NSDiffableDataSourceSnapshot` 驱动刷新,分页追加时保留插入动画 +- 布局复用 `CollectionDiffableLayout` 全宽 section;最后一项日明细 `willDisplay` 时触发 `loadMore` diff --git a/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift index ccd9130..bc34594 100644 --- a/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift +++ b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift @@ -6,39 +6,191 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 统计页 section 标识,区分空状态、周期切换、汇总与每日明细。 +private enum StatisticsSection: Hashable { + case empty + case period + case summary + case daily +} + +/// 统计页 item 标识,携带展示数据以保证 diff 后 Cell 内容同步更新。 +private enum StatisticsItem: Hashable { + case emptyContext + case period(StatisticsPeriod) + case summary(StatisticsSummaryResponse) + case dailyEmpty + case daily(StatisticsDailyItem) +} + /// 数据 Tab 根页面,展示订单统计汇总和每日明细。 final class StatisticsViewController: UIViewController { private let viewModel = StatisticsViewModel() - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.dataSource = self - table.delegate = self - table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID) - table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID) - table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(StatisticsPeriodCell.self, forCellWithReuseIdentifier: StatisticsPeriodCell.reuseID) + collection.register(StatisticsSummaryCell.self, forCellWithReuseIdentifier: StatisticsSummaryCell.reuseID) + collection.register(StatisticsDailyCell.self, forCellWithReuseIdentifier: StatisticsDailyCell.reuseID) + collection.register(StatisticsEmptyCell.self, forCellWithReuseIdentifier: StatisticsEmptyCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() private lazy var refreshControl = UIRefreshControl() + /// Diffable 数据源,驱动多 section 列表与分页插入动画。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "数据" view.backgroundColor = AppDesignUIKit.pageBackground - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + configureDataSource() + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) - tableView.refreshControl = refreshControl + collectionView.refreshControl = refreshControl - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applySnapshot() } appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } + applySnapshot(animated: false) Task { await reload(showLoading: true) } } + /// 构建 Compositional Layout,按 section 类型分配全宽卡片布局。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self else { return CollectionDiffableLayout.fullWidthSection() } + let snapshot = self.dataSource?.snapshot() + let section = snapshot?.sectionIdentifiers[safe: sectionIndex] + switch section { + case .empty: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 360) + case .period: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 56) + case .summary: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 280) + case .daily: + return CollectionDiffableLayout.addHeader( + to: CollectionDiffableLayout.fullWidthSection(estimatedHeight: 52), + height: 36 + ) + case .none: + return CollectionDiffableLayout.fullWidthSection() + } + } + } + + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + switch item { + case .emptyContext: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsEmptyCell.reuseID, + for: indexPath + ) as! StatisticsEmptyCell + let empty = self.makeEmptyStateView( + title: "缺少经营上下文", + message: "请先在首页选择景区后查看数据看板。", + systemImage: "chart.bar.doc.horizontal" + ) + cell.setHostedView(empty, height: 360) + return cell + case .period(let selectedPeriod): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsPeriodCell.reuseID, + for: indexPath + ) as! StatisticsPeriodCell + cell.configure(selectedPeriod: selectedPeriod) { [weak self] period in + self?.selectPeriod(period) + } + return cell + case .summary(let summary): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsSummaryCell.reuseID, + for: indexPath + ) as! StatisticsSummaryCell + cell.configure( + periodText: self.viewModel.selectedPeriod.selectedTimeText, + summary: summary, + amountText: self.amountText + ) + return cell + case .dailyEmpty: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsEmptyCell.reuseID, + for: indexPath + ) as! StatisticsEmptyCell + cell.configurePlainText("暂无数据", height: 52) + return cell + case .daily(let dailyItem): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsDailyCell.reuseID, + for: indexPath + ) as! StatisticsDailyCell + cell.configure(item: dailyItem, amountText: self.amountText) + return cell + } + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + section == .daily, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + header.configure(title: "每日明细") + return header + } + } + + /// 根据 ViewModel 状态构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + if currentScenicId == nil { + snapshot.appendSections([.empty]) + snapshot.appendItems([.emptyContext], toSection: .empty) + } else { + snapshot.appendSections([.period, .summary, .daily]) + snapshot.appendItems([.period(viewModel.selectedPeriod)], toSection: .period) + snapshot.appendItems([.summary(viewModel.summary)], toSection: .summary) + + if viewModel.loading, viewModel.dailyItems.isEmpty { + // 首次加载中不展示占位行,避免闪烁。 + } else if viewModel.dailyItems.isEmpty { + snapshot.appendItems([.dailyEmpty], toSection: .daily) + } else { + let items = viewModel.dailyItems.map { StatisticsItem.daily($0) } + snapshot.appendItems(items, toSection: .daily) + } + } + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 下拉刷新触发。 @objc private func refreshPulled() { Task { await reload(showLoading: false) @@ -49,9 +201,10 @@ final class StatisticsViewController: UIViewController { private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 重新加载汇总与第一页日数据。 private func reload(showLoading: Bool) async { guard currentScenicId != nil else { - tableView.reloadData() + applySnapshot() return } do { @@ -68,6 +221,7 @@ final class StatisticsViewController: UIViewController { } } + /// 切换统计周期并重新加载数据。 private func selectPeriod(_ period: StatisticsPeriod) { Task { do { @@ -85,6 +239,7 @@ final class StatisticsViewController: UIViewController { } } + /// 加载每日明细下一页。 private func loadMore() async { do { try await viewModel.loadMore( @@ -97,85 +252,37 @@ final class StatisticsViewController: UIViewController { } } + /// 格式化金额展示文本。 private func amountText(_ value: Double) -> String { "¥\(String(format: "%.2f", value))" } } -extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - currentScenicId == nil ? 1 : 3 - } +// MARK: - UICollectionViewDelegate - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if currentScenicId == nil { return 1 } - switch section { - case 0: return 1 - case 1: return 1 - default: - if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 } - return max(viewModel.dailyItems.count, 1) - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if currentScenicId == nil { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) } - return cell - } - - switch indexPath.section { - case 0: - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell - cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in - self?.selectPeriod(period) - } - return cell - case 1: - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell - cell.configure( - periodText: viewModel.selectedPeriod.selectedTimeText, - summary: viewModel.summary, - amountText: amountText - ) - return cell - default: - if viewModel.dailyItems.isEmpty { - let cell = UITableViewCell() - cell.textLabel?.text = "暂无数据" - cell.selectionStyle = .none - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell - cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText) - return cell - } - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 2 ? "每日明细" : nil - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return } +extension StatisticsViewController: UICollectionViewDelegate { + /// 最后一项日明细即将展示时触发分页加载。 + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .daily(let dailyItem) = item, + dailyItem.date == viewModel.dailyItems.last?.date + else { return } Task { await loadMore() } } } -private final class StatisticsPeriodCell: UITableViewCell { +// MARK: - Cells + +/// 统计周期切换 Cell,横向展示快捷时间段按钮。 +private final class StatisticsPeriodCell: UICollectionViewCell { static let reuseID = "StatisticsPeriodCell" private var onSelect: ((StatisticsPeriod) -> Void)? private let stack = UIStackView() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear stack.axis = .horizontal stack.spacing = 8 stack.distribution = .fillEqually @@ -186,6 +293,7 @@ private final class StatisticsPeriodCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) { self.onSelect = onSelect stack.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -202,19 +310,22 @@ private final class StatisticsPeriodCell: UITableViewCell { } } + /// 点击周期按钮后回调选中项。 @objc private func periodTapped(_ sender: UIButton) { let period = StatisticsPeriod.allCases[sender.tag] onSelect?(period) } } -private final class StatisticsSummaryCell: UITableViewCell { +/// 统计汇总 Cell,展示订单金额、数量、客单价等指标卡片。 +private final class StatisticsSummaryCell: UICollectionViewCell { static let reuseID = "StatisticsSummaryCell" private let stack = UIStackView() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear stack.axis = .vertical stack.spacing = 8 contentView.addSubview(stack) @@ -224,6 +335,7 @@ private final class StatisticsSummaryCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) { stack.arrangedSubviews.forEach { $0.removeFromSuperview() } let dateLabel = UILabel() @@ -249,6 +361,7 @@ private final class StatisticsSummaryCell: UITableViewCell { stack.addArrangedSubview(row2) } + /// 创建统计摘要卡片视图。 private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView { let card = UIView() card.backgroundColor = color.withAlphaComponent(0.08) @@ -270,21 +383,94 @@ private final class StatisticsSummaryCell: UITableViewCell { } } -private final class StatisticsDailyCell: UITableViewCell { +/// 每日统计明细 Cell,展示日期、订单数与实收金额。 +private final class StatisticsDailyCell: UICollectionViewCell { static let reuseID = "StatisticsDailyCell" - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + private let titleLabel = UILabel() + private let detailLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .secondarySystemGroupedBackground + contentView.backgroundColor = .secondarySystemGroupedBackground + contentView.layer.cornerRadius = 8 + contentView.clipsToBounds = true + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + detailLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + detailLabel.textColor = AppDesignUIKit.textSecondary + + contentView.addSubview(titleLabel) + contentView.addSubview(detailLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().inset(12) + } + detailLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(titleLabel) + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.bottom.equalToSuperview().inset(12) + } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(item: StatisticsDailyItem, amountText: (Double) -> String) { - textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) - detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption) - textLabel?.text = item.date - detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))" + titleLabel.text = item.date + detailLabel.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))" + } +} + +/// 空状态 Cell,用于缺少景区上下文或日明细为空时的占位展示。 +private final class StatisticsEmptyCell: UICollectionViewCell { + static let reuseID = "StatisticsEmptyCell" + + private var heightConstraint: Constraint? + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// 嵌入外部构建的空状态视图并固定高度。 + func setHostedView(_ view: UIView, height: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + heightConstraint?.deactivate() + contentView.addSubview(view) + view.snp.makeConstraints { make in + make.edges.equalToSuperview() + heightConstraint = make.height.equalTo(height).constraint + } + } + + /// 配置纯文案占位,用于日明细为空场景。 + func configurePlainText(_ title: String, height: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + heightConstraint?.deactivate() + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + label.textColor = AppDesignUIKit.textSecondary + label.textAlignment = .center + contentView.addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + heightConstraint = make.height.equalTo(height).constraint + } + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift index 15655b6..556f1ae 100644 --- a/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift +++ b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class TaskManagementViewController: ModuleTableViewController { private let viewModel = TaskManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "任务管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,15 +24,18 @@ final class TaskManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.tasks.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let task = viewModel.tasks[indexPath.row] cell.configure(title: task.name, subtitle: task.statusName, detail: task.createdAt) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let task = viewModel.tasks[indexPath.row] navigationController?.pushViewController( @@ -40,15 +44,18 @@ final class TaskManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { try? await viewModel.reload(api: services.taskAPI, scenicId: services.currentScenicId) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.tasks.count - 2 else { return } Task { try? await viewModel.loadMore(api: services.taskAPI, scenicId: services.currentScenicId) } } + /// 创建任务。 @objc private func createTask() { navigationController?.pushViewController(TaskCreateViewController(), animated: true) } @@ -62,6 +69,7 @@ final class TaskDetailViewController: ModuleTableViewController { private let summary: PhotographerTaskItem? private let viewModel = TaskDetailViewModel() + /// 初始化实例。 init(taskId: Int, summary: PhotographerTaskItem?) { self.taskId = taskId self.summary = summary @@ -73,6 +81,7 @@ final class TaskDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = summary?.name ?? "任务详情" super.viewDidLoad() @@ -81,11 +90,13 @@ final class TaskDetailViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { guard let detail = viewModel.detail else { return summary == nil ? 0 : 4 } return 6 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { if let detail = viewModel.detail { switch indexPath.row { @@ -106,6 +117,7 @@ final class TaskDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.taskAPI, taskId: taskId) } @@ -119,6 +131,7 @@ final class TaskCreateViewController: ModuleTableViewController { private let nameField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "发布任务" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -132,6 +145,7 @@ final class TaskCreateViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { nameField.placeholder = "任务名称" nameField.borderStyle = .roundedRect @@ -146,10 +160,12 @@ final class TaskCreateViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.availableOrders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let order = viewModel.availableOrders[indexPath.row] let selected = viewModel.selectedOrder?.orderNumber == order.orderNumber @@ -157,14 +173,17 @@ final class TaskCreateViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.selectedOrder = viewModel.availableOrders[indexPath.row] } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadAvailableOrders(api: services.taskAPI, scenicId: services.currentScenicId) } + /// 提交。 @objc private func submit() { viewModel.taskName = nameField.text ?? "" viewModel.remark = remarkField.text ?? "" diff --git a/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift index 363c950..5fcf18e 100644 --- a/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift +++ b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift @@ -166,6 +166,7 @@ final class TaskCreateViewModel { /// 校验表单并按需写入错误提示。 private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool { + /// 记录校验失败并返回 false。 func fail(_ message: String) -> Bool { if shouldSetError { errorMessage = message } return false diff --git a/suixinkan_ios/Features/Wallet/Models/WalletModels.swift b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift index 523052d..5200eda 100644 --- a/suixinkan_ios/Features/Wallet/Models/WalletModels.swift +++ b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift @@ -13,6 +13,7 @@ struct WalletSummaryResponse: Decodable, Equatable { let amountCurrentBalance: String let amountWithdrawable: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amountTotal = "amount_total" case amountCurrentBalance = "amount_current_balance" @@ -42,6 +43,7 @@ struct WalletEarningDetailResponse: Decodable, Equatable { let total: Int let list: [WalletEarningDetailGroup] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case totalAmount = "total_amount" case totalPoints = "total_points" @@ -76,6 +78,7 @@ struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable { var id: String { date } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case date case dayAmount = "day_amount" @@ -113,6 +116,7 @@ struct WalletEarningDetailItem: Decodable, Equatable, Identifiable { let withdrawLabel: String? let source: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case amount @@ -168,6 +172,7 @@ struct WalletWithdrawListResponse: Decodable, Equatable { let total: Int let item: [WalletWithdrawRecord] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case item @@ -197,6 +202,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable { let auditTime: String? let completedAt: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case amount @@ -224,6 +230,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable { struct BankCardInfoResponse: Decodable, Equatable { let bankCard: WalletBankCardInfo? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case bankCard = "bank_card" } @@ -244,6 +251,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable { let auditStatus: Int let auditStatusLabel: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case bankName = "bank_name" @@ -279,6 +287,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable { struct BankListResponse: Decodable, Equatable { let banks: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case banks } @@ -296,6 +305,7 @@ struct AreaNode: Decodable, Equatable, Identifiable { let name: String let children: [AreaNode] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -324,6 +334,7 @@ struct WithdrawInfoResponse: Decodable, Equatable { let bankCard: WithdrawBankCardInfo let withdrawInfo: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amountWithdrawable = "amount_withdrawable" case minWithdrawAmount = "min_withdraw_amount" @@ -353,6 +364,7 @@ struct WithdrawBankCardInfo: Decodable, Equatable { let bankName: String let cardNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case bankName = "bank_name" @@ -380,6 +392,7 @@ struct WithdrawApplyRequest: Encodable { let amount: String let smsCode: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amount case smsCode = "sms_code" @@ -398,6 +411,7 @@ struct UpdateBankInfoRequest: Encodable, Equatable { let cityCode: String let smsVerifyCode: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case cardNumber = "card_number" @@ -419,6 +433,7 @@ struct PointOverviewResponse: Decodable, Equatable { let pendingPoints: Int let time: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case totalPoints = "total_points" case availablePoints = "available_points" @@ -459,6 +474,7 @@ struct PointWithdrawListRequest: Encodable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case status case page @@ -471,6 +487,7 @@ struct PointWithdrawListResponse: Decodable, Equatable { let total: Int let list: [PointWithdrawItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case list @@ -498,6 +515,7 @@ struct PointWithdrawItem: Decodable, Equatable, Identifiable { let status: Int let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case points diff --git a/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift index e39b9a1..cbfaf08 100644 --- a/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift +++ b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift @@ -12,6 +12,7 @@ final class WalletViewController: ModuleTableViewController { private let viewModel = WalletViewModel() private let summaryLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "我的钱包" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -25,6 +26,7 @@ final class WalletViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateSummary() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { summaryLabel.numberOfLines = 0 summaryLabel.font = .systemFont(ofSize: 14) @@ -34,6 +36,7 @@ final class WalletViewController: ModuleTableViewController { tableView.tableHeaderView = summaryLabel } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { switch viewModel.selectedTab { case .earnings: @@ -43,6 +46,7 @@ final class WalletViewController: ModuleTableViewController { } } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { switch viewModel.selectedTab { case .earnings: @@ -55,16 +59,19 @@ final class WalletViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadInitial(api: services.walletAPI, staffId: services.staffId) updateSummary() } + /// 更新Summary状态。 private func updateSummary() { summaryLabel.text = "\(viewModel.withdrawableText) · \(viewModel.totalAmountText)" navigationItem.rightBarButtonItem?.title = viewModel.selectedTab == .earnings ? "提现记录" : "收益明细" } + /// openWithdraw相关逻辑。 @objc private func openWithdraw() { navigationController?.pushViewController(WalletWithdrawViewController(), animated: true) } @@ -78,6 +85,7 @@ final class WalletWithdrawViewController: ModuleTableViewController { private let amountField = UITextField() private let smsField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "申请提现" navigationItem.rightBarButtonItems = [ @@ -100,21 +108,26 @@ final class WalletWithdrawViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 1 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let amount = viewModel.info?.amountWithdrawable ?? "0" cell.configure(title: "可提现", subtitle: "¥ \(amount)") } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.walletAPI) } + /// sendSms相关逻辑。 @objc private func sendSms() { Task { await viewModel.sendSms(api: services.walletAPI) } } + /// 提交。 @objc private func submit() { viewModel.amountText = amountField.text ?? "" viewModel.smsCode = smsField.text ?? "" diff --git a/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift index 224b7ec..e7ea56c 100644 --- a/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift +++ b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift @@ -11,6 +11,7 @@ import UIKit final class WithdrawalAuditViewController: ModuleTableViewController { private let viewModel = WithdrawalAuditViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "提现审核" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,25 +24,30 @@ final class WithdrawalAuditViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateFilterTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredRecords.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = viewModel.filteredRecords[indexPath.row] cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.walletAPI) updateFilterTitle() } + /// willDisplayTableRow 回调处理。 override func willDisplayTableRow(at indexPath: IndexPath) { guard indexPath.row >= viewModel.filteredRecords.count - 2 else { return } Task { await viewModel.loadMore(api: services.walletAPI) } } + /// cycle筛选相关逻辑。 @objc private func cycleFilter() { let filters = WithdrawalAuditFilter.allCases guard let index = filters.firstIndex(of: viewModel.selectedFilter) else { return } @@ -52,6 +58,7 @@ final class WithdrawalAuditViewController: ModuleTableViewController { } } + /// 更新FilterTitle状态。 private func updateFilterTitle() { navigationItem.rightBarButtonItem?.title = viewModel.selectedFilter.title } diff --git a/suixinkan_ios/Info.plist b/suixinkan_ios/Info.plist index 0eb786d..4d2b652 100644 --- a/suixinkan_ios/Info.plist +++ b/suixinkan_ios/Info.plist @@ -2,6 +2,20 @@ + AMapAPIKey + + NSCameraUsageDescription + 用于扫码核销订单 + NSLocationWhenInUseUsageDescription + 用于景区选择、打卡点选点、运营区域和位置上报时获取当前位置 + NSPhotoLibraryAddUsageDescription + 用于保存收款二维码到相册 + NSPhotoLibraryUsageDescription + 用于选择头像、证件和打卡点图片 + UIBackgroundModes + + remote-notification + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan_ios/SceneDelegate.swift b/suixinkan_ios/SceneDelegate.swift index df5c7bf..161ecf4 100644 --- a/suixinkan_ios/SceneDelegate.swift +++ b/suixinkan_ios/SceneDelegate.swift @@ -7,10 +7,12 @@ import UIKit +/// 窗口 Scene 代理,负责创建根窗口并挂载 `RootViewController`。 class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + /// Scene 连接时初始化窗口、重置 UI 测试状态并展示根控制器。 func scene( _ scene: UIScene, willConnectTo session: UISceneSession, @@ -26,18 +28,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window } + /// Scene 断开连接,当前无额外资源释放。 func sceneDidDisconnect(_ scene: UIScene) { } + /// Scene 进入活跃态,当前无额外处理。 func sceneDidBecomeActive(_ scene: UIScene) { } + /// Scene 即将失活,当前无额外处理。 func sceneWillResignActive(_ scene: UIScene) { } + /// Scene 即将进入前台,当前无额外处理。 func sceneWillEnterForeground(_ scene: UIScene) { } + /// Scene 进入后台,当前无额外处理。 func sceneDidEnterBackground(_ scene: UIScene) { } } diff --git a/suixinkan_iosTests/NavigationRouterTests.swift b/suixinkan_iosTests/NavigationRouterTests.swift index 344664a..f4d37d5 100644 --- a/suixinkan_iosTests/NavigationRouterTests.swift +++ b/suixinkan_iosTests/NavigationRouterTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import suixinkan_ios /// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。 +@MainActor final class NavigationRouterTests: XCTestCase { /// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。 func testPushedRoutesHideTabBarByDefault() { diff --git a/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift index da9e55b..df5c3a2 100644 --- a/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift +++ b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift @@ -212,6 +212,7 @@ private enum PilotTestError: LocalizedError { var errorDescription: String? { "测试错误" } } +@MainActor private func fillValidForm(_ viewModel: PilotCertificationViewModel) { viewModel.name = "航拍摄影师" viewModel.certType = .caac diff --git a/suixinkan_iosTests/PushNotificationTests.swift b/suixinkan_iosTests/PushNotificationTests.swift index 1511ca6..14c170a 100644 --- a/suixinkan_iosTests/PushNotificationTests.swift +++ b/suixinkan_iosTests/PushNotificationTests.swift @@ -10,6 +10,7 @@ import XCTest @testable import suixinkan_ios /// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。 +@MainActor final class PushNotificationTests: XCTestCase { func testAPNsDeviceTokenUsesLowercaseHexString() { let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff])) diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 67b67ac..9200ae0 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -1,152 +1,133 @@ # 功能同步 Checklist(UIKit 工程) 更新时间:2026-06-26 -工程:`suixinkan_ios_new_uikit`(Swift + UIKit + SnapKit) +工程:[`suixinkan_ios_new_uikit`](.)(Swift + UIKit + SnapKit) +参考:[`../suixinkan_ios_new`](../suixinkan_ios_new)(SwiftUI) -## UIKit 同步进度摘要 +## 同步进度摘要 -| 阶段 | 状态 | +| 维度 | 状态 | | --- | --- | -| 阶段 0 工程脚手架 | 完成 | -| 阶段 1 Core 基础设施 | 完成 | -| 阶段 2 登录与会话 | 完成 | -| 阶段 3 Tab 壳与导航 | 完成 | -| 阶段 4 四 Tab 主功能(里程碑 1) | 完成 | -| 阶段 5 首页子业务模块 | 完成 | -| 阶段 6 平台能力(Lottie/Push/AMap 占位) | 完成 | -| 阶段 7 单元测试验收 | 完成(`xcodebuild test` 全绿) | +| 代码同步(阶段 0–5) | **完成** — Core / 登录 / 四 Tab / 子业务模块均有 UIKit 实现 | +| 单元测试 | **完成** — `xcodebuild test` 全绿 | +| 高德地图 | **完成(真机)** — Pod 已接入;模拟器自动降级 | +| 推送 | **完成** — `PushNotificationManager` + `AppDelegate` APNs 回调 | +| UI 视觉还原 | **部分完成** — 功能对齐,细节待逐页走查 | +| XCUITest | **未开始** — 可选后续补充 | ## 状态说明 -- 完成:UI、核心功能、文档和必要单元测试已同步,并通过编译验证。 -- 部分完成:已有入口、壳、路由或部分主流程,但旧工程完整业务尚未迁移。 -- 未开始:new 工程尚无对应真实业务页面或服务。 -- 不迁移:已明确不纳入 iOS 迁移范围。 +- **完成**:UIKit 页面、ViewModel、必要单测已同步,编译与测试通过。 +- **部分完成**:主流程可用,但与参考工程在 UI 细节或平台能力上仍有差距。 +- **未开始**:UIKit 工程尚无对应实现。 +- **不迁移**:参考工程已明确不纳入 iOS 范围。 + +--- ## 基础架构 -| 状态 | 模块 | 同步内容 | 剩余事项 | +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | -| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | -| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | -| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | -| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | -| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | -| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | -| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | -| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | +| 完成 | App 根结构 | `RootViewController` 按 `AppSession.phase` 切换登录 / 恢复 / 主 Tab | 持续随路由扩展 | +| 完成 | Tab 导航 | 系统 `UITabBarController` 四 Tab + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 | +| 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — | +| 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — | +| 完成 | OSS 上传 | `UploadAPI` + `OSSUploadService` | — | +| 完成 | 网络图片 | `UIImageView` + Kingfisher 扩展 | — | +| 完成 | 设计常量 | `AppDesign`(UIColor)+ `AppMetrics` | 新页继续复用 | +| 完成 | 文档规范 | `AGENTS.md` + 各 Feature 模块 `.md` | 新模块同步补文档 | -## 登录与账号上下文 +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 平台能力 + +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 | -| 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 | -| 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 | -| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 打卡点管理页面已接入,后续可继续增强地图交互。 | -| 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 | +| 完成 | Core/Map | CocoaPods 高德 SDK;`OperatingAreaMapView` 围栏 polygon;`PunchPointMapPickerView` 点选 + 逆地理;模拟器坐标摘要兜底 | 在 `Info.plist` 填写 `AMapAPIKey` 后真机验证 | +| 完成 | Core/Push | `PushNotificationManager`;`AppDelegate` 注册 token 与远程通知 | 真机验证 APNs 与后端联调 | +| 完成 | Core/Queue | `ScenicQueueRuntime` WebSocket + 叫号 | 真机验证语音播报策略 | +| 部分完成 | Core/Validation | `AppFormValidator` + 各表单页内联校验 | 未单独抽离全部校验规则 | +| 完成 | Lottie Loading | SPM `Lottie` + `GlobalLoadingOverlayView` | — | +| 不迁移 | 旧 UIKit 兼容页 | — | 参考工程 legacy UIKit 目录不迁移 | -## 首页 +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 登录与账号 + +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | 首页主 UI | 顶部景区、工作状态、位置上报卡、快捷操作、门店卡、常用应用网格。 | 视觉可随旧工程细节继续微调。 | -| 完成 | 首页权限菜单 | 从 `PermissionContext` 构建菜单,支持 URI 别名去重和旧工程排序。 | 未知 URI 继续通过诊断报告跟踪。 | -| 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 | -| 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 | -| 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 | -| 完成 | 景区选择入口 | 旧工程同款景区选择页、搜索、定位距离、景区卡片、封面、地址、营业状态、切换景区并持久化。 | 后续如有景区选择视觉细节变化再微调。 | -| 完成 | 权限申请入口 | `permission_apply`、`permission_apply_status`、`scenicapplication` 已接真实页面。 | 景区结算仍按独立模块后续迁移。 | +| 完成 | 登录页 | `LoginViewController` | — | +| 完成 | 多账号选择 | `AccountSelectionViewController` | — | +| 完成 | 账号上下文 | `AccountContext` / `PermissionContext` / `ScenicSpotContext` | — | +| 完成 | 权限菜单 | `HomeMenuRouter` URI 映射 | 未知 URI 进占位页并记录诊断 | -## 订单 Tab +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 四 Tab 主功能 + +| Tab | 状态 | UIKit 入口 | 备注 | | --- | --- | --- | --- | -| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续继续补长尾订单流程。 | -| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 | -| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 | -| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 | -| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 无。 | -| 完成 | 押金订单 | 押金订单列表、手输订单号查详情、押金详情、押金核销、押金退款、押金拍摄信息。 | 后续可继续补押金订单 UI 细节和异常空态。 | -| 完成 | 退款流程 | 普通订单退款入口规则、全额/部分退款金额校验、退款提交和详情刷新。 | 复杂退款审核进度如后端需要可单独补。 | -| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 | -| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 | -| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 | +| 首页 | 完成 | `HomeViewController` | 权限菜单、常用应用、景区选择 | +| 订单 | 完成 | `OrdersViewController` | 含扫码、详情、押金、退款、尾片等 | +| 数据 | 完成 | `StatisticsViewController` | 时间段 + 汇总 + 日明细 | +| 我的 | 完成 | `ProfileViewController` | 头像 OSS、改密、实名、设置、协议 | -## 数据 Tab - -| 状态 | 模块 | 同步内容 | 剩余事项 | -| --- | --- | --- | --- | -| 完成 | 数据看板 | 今日、昨日、7 日、本月时间段切换;统计汇总卡;每日明细。 | 后续按旧工程继续补图表细节。 | -| 完成 | 数据分页 | 日数据分页加载、切换时间段重置分页、加载失败保留当前数据。 | 持续跟随后端字段变化。 | -| 完成 | 角色接口分流 | 摄影师/景区管理员两套统计接口。 | 其他角色如有特殊逻辑再补。 | - -## 个人中心 - -| 状态 | 模块 | 同步内容 | 剩余事项 | -| --- | --- | --- | --- | -| 完成 | 个人信息首页 | 头像、昵称、UID、姓名、手机号、账号状态、当前景区、退出登录。 | 后续可继续细化旧工程视觉。 | -| 完成 | 头像上传 | `PhotosPicker` 选图、压缩、OSS 上传、回写头像 URL、刷新账号快照。 | 后续可补裁剪体验。 | -| 完成 | 昵称编辑 | 昵称校验、保存、同步 `AccountContext`。 | 无。 | -| 完成 | 修改密码 | 密码弹窗、最小长度校验、接口提交。 | 无。 | -| 完成 | 账号切换 | 可切换账号列表、默认选中、set-user、刷新登录上下文。 | 无。 | -| 完成 | 实名认证 | 信息加载、短信验证码、身份证校验、证件图片选择、OSS 上传、提交审核。 | 后续可补示例图或拍照入口。 | -| 完成 | 系统设置 | 关于我们、版本号、下载链接、协议入口。 | 若旧工程有更多设置项需继续核对。 | -| 完成 | 协议页 | 关于我们、用户协议、隐私政策、钱包协议页面。 | 无。 | +--- ## 首页子业务模块 -| 状态 | 模块 | 当前情况 | 后续事项 | -| --- | --- | --- | --- | -| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | -| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 | -| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 | -| 完成 | 排队管理 | `/scenic-queue` / `queue_management` 已接真实页面;支持队列列表、叫号、过号、完成、重新排队、用户标记、设置、二维码、日志、实时监听和页面离开后的短轮询。 | 后续可继续按真实设备验证语音播报和后台活跃策略。 | -| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | -| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | -| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | -| 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | -| 完成 | 日程管理 | 月份切换、日期标记、日程列表、下拉刷新、删除日程、新增排班、关联订单或手填订单号兜底。 | 后续如旧工程有排班详情页或高级重复规则再继续迁移。 | -| 完成 | 打卡点管理 | 列表、筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传已接入。 | 后续可把高德地图完整选点 UI 替换进编辑页。 | -| 完成 | 摄影师项目管理 | `pm` / `project_edit` 已接真实页面;支持列表、搜索、分页、详情、新建、编辑、删除和项目图片 OSS 上传。 | 后续可继续补项目数据分析、项目订单深层流程。 | -| 完成 | 店铺项目管理 | `pm_manager` 已接真实页面;支持店铺项目列表、详情、新建、编辑、删除、多点位项目和押金项目。 | 后续如有店铺后台审核扩展再单独迁移。 | -| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | -| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | -| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | -| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | -| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | -| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 | -| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | -| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | -| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | -| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | -| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | -| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | -| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | -| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | - -## 旧工程能力尚未系统迁移 - -| 状态 | 模块 | 说明 | +| 状态 | 模块 | UIKit ViewController | | --- | --- | --- | -| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 | -| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | -| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | -| 完成 | Core/Queue | 已迁移排队 WebSocket、远端叫号去重、语音播报和短轮询运行时。 | -| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 | -| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 | +| 完成 | 钱包 | `WalletViewControllers` | +| 完成 | 提现审核 | `WithdrawalAuditViewController` | +| 完成 | 消息中心 | `MessageCenterViewController` | +| 完成 | 排队管理 | `QueueManagementViewControllers` | +| 完成 | 立即收款 | `PaymentCollectionViewController` | +| 完成 | 任务 / 发布任务 | `TaskViewControllers` | +| 完成 | 日程管理 | `ScheduleViewControllers` | +| 完成 | 打卡点管理 | `PunchPointViewControllers`(含地图选点) | +| 完成 | 项目管理 / 店铺项目 | `ProjectViewControllers` | +| 完成 | 相册 / 云盘 / 素材 / 样片 | `AssetsViewControllers` | +| 完成 | 直播 / 直播相册 | `LiveViewControllers` | +| 完成 | 景区 / 权限 / 结算 | `ScenicPermissionViewControllers` / `ScenicSettlementViewControllers` | +| 完成 | 运营区域 | `OperatingAreaViewController`(含围栏地图) | +| 完成 | 位置上报 | `LocationReportViewControllers` | +| 完成 | 注册邀请 | `InviteViewControllers` | +| 完成 | 飞手认证 | `PilotCertificationViewController` | +| 不迁移 | 飞控 / DJI | `HomeMenuRouter` 标记 unsupported | -## 最近验证记录 +--- -已通过: +## 已知差异(相对 SwiftUI 参考工程) + +| 项目 | 说明 | +| --- | --- | +| UI 视觉 | UITableView 功能型布局,非 SwiftUI 像素级还原 | +| 直播推流 | 无真 RTMP SDK,外部 `push_url` + 诊断层(与参考工程一致) | +| 订单尾片 | 无独立尾片审核进度接口 | +| 高德 Key | 需在 `Info.plist` → `AMapAPIKey` 填入控制台 Key(Bundle ID: `com.hanqiu.suixinkan-ios`) | + +--- + +## 验证命令 ```bash -xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' -xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'generic/platform=iOS Simulator' +# 编译(模拟器) +xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios \ + -destination 'generic/platform=iOS Simulator' build + +# 单元测试 +xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios \ + -destination 'platform=iOS Simulator,name=iPhone 17' ``` -## 下一批建议迁移顺序 +最近验证:**BUILD SUCCEEDED** / **TEST SUCCEEDED**(2026-06-26) -1. 直播真推流 SDK:旧 iOS 未接入真推流 SDK,当前已按旧 iOS 对齐为外部 `push_url` + 推流诊断;如后续需要真 SDK,可在腾讯/TRTC、RTMP SDK 等方案明确后替换 `LivePushAdapter` 实现并补真机联调。 -2. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。 +--- + +## 后续可选 + +1. 真机填入 `AMapAPIKey` 并验证运营区域围栏、打卡点地图选点。 +2. 逐页 UI 走查,对齐 SwiftUI 参考工程视觉与交互细节。 +3. 新增 `suixinkan_iosUITests` 做关键流程自动化。